feat(subscription): add membership access preflight

This commit is contained in:
chengma
2026-07-21 17:38:05 +08:00
parent ca46c34dfe
commit 9ee48dc2aa
14 changed files with 788 additions and 4 deletions
+31
View File
@@ -1753,6 +1753,37 @@ def _cmhub_call_with_retry(
raise last_exc
def request_cmhub_json(
method,
base_url,
endpoint,
api_key,
*,
payload=None,
connect_timeout=10,
read_timeout=30,
use_system_proxy=False,
headers_extra=None,
):
"""Call one cmhub JSON endpoint through the shared gateway session.
This is intentionally small: product-specific modules can reuse the same
proxy handling, connection pool, redaction and structured HTTP errors as
generation without reaching into private transport helpers.
"""
_apply_cmhub_proxy(use_system_proxy)
return _cmhub_call_once(
method,
appconfig.cmhub_request_url(base_url, endpoint),
api_key,
payload,
connect_timeout=connect_timeout,
read_timeout=read_timeout,
headers_extra=headers_extra,
)
def _cmhub_call_once(method, url, api_key, payload, connect_timeout, read_timeout, headers_extra=None):
headers = {
"Authorization": "Bearer " + str(api_key),
+22
View File
@@ -122,6 +122,9 @@ DEFAULT_CONFIG = {
"ratio": "1:1",
},
},
"subscription": {
"last_notice_id": "",
},
}
DEFAULT_AI_MODELS_CONFIG = {
@@ -706,6 +709,25 @@ def cdp_ready_timeout(config=None) -> int:
return int(_config_or_load(config).get("cdp_ready_timeout", 60))
def subscription_notice_id(config=None) -> str:
section = _config_or_load(config).get("subscription", {})
if not isinstance(section, dict):
return ""
return str(section.get("last_notice_id") or "").strip()
def save_subscription_notice_id(notice_id, path=CONFIG_PATH) -> dict:
"""Persist the last server notification version without storing credentials."""
config = load_config(path)
section = config.get("subscription", {})
if not isinstance(section, dict):
section = {}
section["last_notice_id"] = str(notice_id or "").strip()
config["subscription"] = section
return save_config(config, path=path)
def ai_config(config=None) -> dict:
return copy.deepcopy(_config_or_load(config).get("ai", DEFAULT_CONFIG["ai"]))
+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"),
+218 -1
View File
@@ -2,6 +2,10 @@
from __future__ import annotations
from PySide6.QtCore import QUrl
from PySide6.QtGui import QDesktopServices
from .. import subscription
from ..version import display_name
from .tabs.accounts import AccountsTab
from .tabs.apply import ApplyTab
@@ -10,6 +14,7 @@ from .tabs.generate import GenerateTab
from .tabs.product_suite import ProductSuiteTab
from .tabs.settings import SettingsTab
from .widgets import *
from .workers import SubscriptionCheckWorker
PREFERRED_WINDOW_SIZE = (1180, 760)
@@ -106,6 +111,14 @@ class MainWindow(QMainWindow):
self._settings_tab_index = TAB_TITLES.index("设置")
self._last_tab_index = 0
self._reverting_tab_change = False
self._subscription_status = subscription.SubscriptionStatus(
subscription.STATUS_UNAVAILABLE
)
self._subscription_worker = None
self._subscription_thread = None
self._subscription_request_token = 0
self._subscription_prompt_key = ""
self._subscription_closed = False
self.tabs = QTabWidget()
self.tabs.setObjectName("mainTabs")
self.tabs.setStyleSheet(TAB_STYLE)
@@ -115,7 +128,30 @@ class MainWindow(QMainWindow):
settings_tab = self._settings_tab()
if hasattr(settings_tab, "settingsSaved"):
settings_tab.settingsSaved.connect(self._on_settings_saved)
self.setCentralWidget(self.tabs)
self._header_title_label = QLabel(display_name())
self._header_title_label.setObjectName("appHeaderTitle")
self._header_title_label.setStyleSheet(
"font-size: 15px; font-weight: 600; color: #24292f;"
)
self._subscription_label = QLabel("会员:正在验证")
self._subscription_label.setObjectName("subscriptionStatusLabel")
self._subscription_label.setAccessibleName("会员状态")
self._subscription_label.setStyleSheet("color: #0969da;")
header = QWidget()
header.setObjectName("appHeader")
header_layout = QHBoxLayout(header)
header_layout.setContentsMargins(18, 8, 18, 4)
header_layout.setSpacing(10)
header_layout.addWidget(self._header_title_label)
header_layout.addStretch(1)
header_layout.addWidget(self._subscription_label)
central = QWidget()
central_layout = QVBoxLayout(central)
central_layout.setContentsMargins(0, 0, 0, 0)
central_layout.setSpacing(0)
central_layout.addWidget(header)
central_layout.addWidget(self.tabs, 1)
self.setCentralWidget(central)
self.show_status("就绪", level="muted")
if startup_status:
self.show_status(startup_status, level="success")
@@ -150,6 +186,7 @@ class MainWindow(QMainWindow):
cover_prompts_dir=appconfig.cover_prompts_dir(self.config),
open_accounts_callback=lambda: self.open_accounts_tab(),
refresh_workflow_callback=lambda: self.refresh_task_tabs(),
subscription_preflight_callback=self.ensure_subscription_for_new_submit,
)
if title == "③ 更新蝦皮":
return ApplyTab(
@@ -179,6 +216,7 @@ class MainWindow(QMainWindow):
config=self.config,
config_path=self.config_path,
status_callback=self.show_status,
subscription_preflight_callback=self.ensure_subscription_for_new_submit,
)
raise ValueError(f"未知主界面模块:{title}")
@@ -195,6 +233,182 @@ class MainWindow(QMainWindow):
widget.refresh_gateway_state()
label = "自定义网关" if str(backend) == "direct" else "默认网关"
self.show_status("设置已保存,当前使用%s" % label, level="success")
self.begin_subscription_check(show_prompt=False)
@property
def subscription_status(self):
return self._subscription_status
def begin_subscription_check(self, *, show_prompt=True):
"""Refresh membership state without blocking the Qt GUI thread."""
self._subscription_request_token += 1
token = self._subscription_request_token
if self._subscription_worker is not None:
self._subscription_worker.cancel()
self._set_product_access(False)
self._set_subscription_label("会员:正在验证", "info")
worker = SubscriptionCheckWorker(
config=self.config,
cmhub_config_path=self.config.get("cmhub_config_path"),
)
worker.finished.connect(
lambda payload, token=token, show_prompt=show_prompt: self._on_subscription_checked(
token,
payload,
show_prompt=show_prompt,
)
)
worker.cancelled.connect(
lambda payload, token=token, show_prompt=show_prompt: self._on_subscription_checked(
token,
payload,
show_prompt=show_prompt,
)
)
thread = run_worker(worker, thread_name="SubscriptionCheckWorker", start=False)
thread.finished.connect(lambda: self._forget_subscription_thread(thread))
self._subscription_worker = worker
self._subscription_thread = thread
thread.start()
def ensure_subscription_for_new_submit(self, action_name="生成"):
"""Return whether a new product request may start from the current UI."""
status = self._subscription_status
if status.allows_product_workflows:
return True
if self._subscription_thread is not None:
self.show_status("正在验证会员状态,请稍后再%s" % action_name, level="info")
return False
self.show_status("%s:%s" % (action_name, status.user_message), level="warning")
self._show_subscription_access_prompt(status)
return False
def _on_subscription_checked(self, token, payload, *, show_prompt=True):
if self._subscription_closed or token != self._subscription_request_token:
return
result = payload.get("subscription") if isinstance(payload, dict) else None
if not isinstance(result, subscription.SubscriptionStatus):
result = subscription.SubscriptionStatus(subscription.STATUS_UNAVAILABLE)
self._subscription_status = result
self._apply_subscription_status(result, show_prompt=show_prompt)
def _forget_subscription_thread(self, thread):
if self._subscription_thread is thread:
self._subscription_thread = None
self._subscription_worker = None
def _apply_subscription_status(self, status, *, show_prompt=True):
self._subscription_status = status
if status.state == subscription.STATUS_ACTIVE:
expiry = subscription.format_expiry(status.expires_at)
self._set_subscription_label(
"%s · %s · 有效至%s" % (status.account_name, status.plan_name, expiry),
"success",
)
elif status.state == subscription.STATUS_GRACE:
expiry = subscription.format_expiry(status.grace_expires_at) or subscription.format_expiry(status.expires_at)
self._set_subscription_label(
"%s · %s · 宽限至%s" % (status.account_name, status.plan_name, expiry),
"warning",
)
else:
self._set_subscription_label("会员:%s" % status.user_message, self._subscription_level(status))
self._set_product_access(status.allows_product_workflows)
if status.allows_product_workflows:
if status.state == subscription.STATUS_LEGACY:
self.show_status(status.user_message, level="muted")
else:
self.show_status("会员状态已验证", level="success")
self._show_subscription_notice_once(status)
return
self.open_settings_tab()
self.show_status(status.user_message, level=self._subscription_level(status))
if show_prompt:
self._show_subscription_access_prompt(status)
@staticmethod
def _subscription_level(status):
if status.state in {subscription.STATUS_UNAVAILABLE, subscription.STATUS_KEY_INVALID}:
return "warning"
return "danger"
def _set_subscription_label(self, text, level):
color = _status_level_color(level)
self._subscription_label.setText(str(text))
self._subscription_label.setStyleSheet("color: %s;" % color)
self._subscription_label.setToolTip(str(text))
def _set_product_access(self, enabled):
for index, title in enumerate(TAB_TITLES):
self.tabs.setTabEnabled(index, bool(enabled) or title == "设置")
def _show_subscription_access_prompt(self, status):
if self._subscription_closed:
return
if status.state in {subscription.STATUS_ACTIVE, subscription.STATUS_GRACE, subscription.STATUS_LEGACY}:
return
prompt_key = "|".join((status.state, status.notice_id, status.manage_url))
if prompt_key and prompt_key == self._subscription_prompt_key:
return
self._subscription_prompt_key = prompt_key
box = QMessageBox(self)
box.setIcon(QMessageBox.Warning)
box.setWindowTitle("需要配置会员账号")
box.setText(status.user_message)
box.setInformativeText("请配置有效的 cmhub API Key,或前往线上会员中心开通、续费后重试。")
settings_button = box.addButton("打开设置", QMessageBox.AcceptRole)
manage_button = None
if status.manage_url:
manage_button = box.addButton("前往会员中心", QMessageBox.ActionRole)
exit_button = box.addButton("退出", QMessageBox.RejectRole)
box.setDefaultButton(settings_button)
box.setEscapeButton(exit_button)
box.exec()
if box.clickedButton() is settings_button:
self.open_settings_tab()
elif manage_button is not None and box.clickedButton() is manage_button:
QDesktopServices.openUrl(QUrl(status.manage_url))
elif box.clickedButton() is exit_button:
self.close()
def _show_subscription_notice_once(self, status):
notice_id = str(status.notice_id or "").strip()
if not notice_id or notice_id == appconfig.subscription_notice_id(self.config):
return
box = QMessageBox(self)
box.setIcon(QMessageBox.Information)
box.setWindowTitle("已启用会员订阅")
expiry = subscription.format_expiry(
status.grace_expires_at if status.state == subscription.STATUS_GRACE else status.expires_at
)
text = "已启用蝦皮圈会员订阅服务。当前账号:%s;套餐:%s。" % (
status.account_name,
status.plan_name,
)
if expiry:
text += "当前有效至%s。" % expiry
text += "请在到期前前往线上会员中心选择或续费套餐。"
box.setText(text)
manage_button = None
if status.manage_url:
manage_button = box.addButton("前往会员中心", QMessageBox.ActionRole)
close_button = box.addButton("关闭", QMessageBox.AcceptRole)
box.setDefaultButton(close_button)
box.exec()
if manage_button is not None and box.clickedButton() is manage_button:
QDesktopServices.openUrl(QUrl(status.manage_url))
try:
saved = appconfig.save_subscription_notice_id(
notice_id,
path=self.config_path,
)
except Exception:
return
self.config.clear()
self.config.update(saved)
def _on_tab_changed(self, index):
if self._reverting_tab_change:
@@ -239,6 +453,9 @@ class MainWindow(QMainWindow):
def closeEvent(self, event):
if self._confirm_leave_settings_tab():
self._subscription_closed = True
if self._subscription_worker is not None:
self._subscription_worker.cancel()
event.accept()
return
event.ignore()
+4
View File
@@ -983,6 +983,7 @@ class GenerateTab(QWidget):
title_templates_dir=None,
open_accounts_callback=None,
refresh_workflow_callback=None,
subscription_preflight_callback=None,
):
super().__init__(parent)
self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config
@@ -995,6 +996,7 @@ class GenerateTab(QWidget):
self.status_callback = status_callback
self.open_accounts_callback = open_accounts_callback
self.refresh_workflow_callback = refresh_workflow_callback
self.subscription_preflight_callback = subscription_preflight_callback
self.title_prompt_path = title_prompt_path or appconfig.title_prompt_path(self.config)
self.cover_prompts_dir = cover_prompts_dir or appconfig.cover_prompts_dir(self.config)
self.title_templates_dir = title_templates_dir or appconfig.title_templates_dir(self.config)
@@ -1757,6 +1759,8 @@ class GenerateTab(QWidget):
if self.generate_thread is not None:
self._set_status("AI 生成正在进行...")
return
if self.subscription_preflight_callback is not None and not self.subscription_preflight_callback("开始 AI 生成"):
return
if not self._save_generate_mode_setting(show_status=False):
return
generate_mode = self._current_generate_mode()
+6
View File
@@ -1973,6 +1973,7 @@ class ProductSuiteTab(QWidget):
config=None,
config_path=None,
status_callback=None,
subscription_preflight_callback=None,
):
super().__init__(parent)
self.setObjectName("productSuiteTab")
@@ -1982,6 +1983,7 @@ class ProductSuiteTab(QWidget):
self.cmhub_config_path = self.config.get("cmhub_config_path") or appconfig.cmhub_config_file_path(self.config)
self.product_suite_prompt_path = appconfig.product_suite_prompt_path(self.config)
self.status_callback = status_callback
self.subscription_preflight_callback = subscription_preflight_callback
self.accounts = []
self._states = {}
self._retired_states = []
@@ -4392,6 +4394,8 @@ class ProductSuiteTab(QWidget):
def start_ai_write(self, checked=False):
if not self._require_default_gateway("商品套图AI帮写"):
return
if self.subscription_preflight_callback is not None and not self.subscription_preflight_callback("开始 AI 帮写"):
return
state = self._displayed_state
if state is None:
return
@@ -4729,6 +4733,8 @@ class ProductSuiteTab(QWidget):
retry_job_id=None,
confirm_direct_retry=False,
):
if self.subscription_preflight_callback is not None and not self.subscription_preflight_callback("生成商品套图"):
return False
if not self._ensure_generation_gateway():
return False
if state.generation_running():
+17
View File
@@ -23,6 +23,7 @@ from .. import (
image_studio_generation,
image_studio_images,
product_status,
subscription,
)
from ..collect_skip import ALIAS_UNMATCHED, LOGIN_REQUIRED, empty_skip_reason_counts
from .widgets import *
@@ -34,6 +35,22 @@ _USER_LOG_PATH_RE = re.compile(
)
class SubscriptionCheckWorker(BaseWorker):
"""Fetch the current cmshopee account subscription away from the GUI thread."""
def __init__(self, *, config=None, cmhub_config_path=None):
super().__init__()
self.config = config
self.cmhub_config_path = cmhub_config_path
def execute(self):
result = subscription.check_status(
self.config,
cmhub_config_path=self.cmhub_config_path,
)
return {"subscription": result}
def _image_studio_step_label(step):
return {
"ensure_chrome": "准备账号浏览器",
+231
View File
@@ -0,0 +1,231 @@
"""cmshopee account subscription status helpers.
The remote service remains the authority for subscription enforcement. This
module only normalizes the status response for desktop UI and preflight use;
it deliberately never persists or exposes the API key it uses.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from urllib.parse import urlsplit
from . import ai, appconfig
PRODUCT_CODE = "cmshopee"
STATUS_NOT_CONFIGURED = "not_configured"
STATUS_ACTIVE = "active"
STATUS_GRACE = "grace"
STATUS_REQUIRED = "required"
STATUS_EXPIRED = "expired"
STATUS_REVOKED = "revoked"
STATUS_ACCOUNT_DISABLED = "account_disabled"
STATUS_KEY_INVALID = "key_invalid"
STATUS_UNAVAILABLE = "unavailable"
STATUS_LEGACY = "legacy"
_ALLOWED_STATES = {
STATUS_NOT_CONFIGURED,
STATUS_ACTIVE,
STATUS_GRACE,
STATUS_REQUIRED,
STATUS_EXPIRED,
STATUS_REVOKED,
STATUS_ACCOUNT_DISABLED,
STATUS_KEY_INVALID,
STATUS_UNAVAILABLE,
STATUS_LEGACY,
}
@dataclass(frozen=True)
class SubscriptionStatus:
"""A redacted, UI-safe view of one cmshopee subscription lookup."""
state: str
account_name: str = ""
plan_name: str = ""
expires_at: str = ""
grace_expires_at: str = ""
manage_url: str = ""
notice_id: str = ""
def __post_init__(self):
if self.state not in _ALLOWED_STATES:
raise ValueError("未知订阅状态")
@property
def allows_product_workflows(self) -> bool:
return self.state in {STATUS_ACTIVE, STATUS_GRACE, STATUS_LEGACY}
@property
def interface_available(self) -> bool:
return self.state != STATUS_LEGACY
@property
def user_message(self) -> str:
return {
STATUS_NOT_CONFIGURED: "请先在设置配置 cmhub API Key",
STATUS_ACTIVE: "会员有效",
STATUS_GRACE: "会员处于宽限期",
STATUS_REQUIRED: "当前账号尚未开通蝦皮圈会员",
STATUS_EXPIRED: "当前账号的蝦皮圈会员已到期",
STATUS_REVOKED: "当前账号的蝦皮圈会员已撤销",
STATUS_ACCOUNT_DISABLED: "cmhub 账号当前不可用",
STATUS_KEY_INVALID: "cmhub API Key 无效,请在设置重新填写",
STATUS_UNAVAILABLE: "暂时无法确认会员状态",
STATUS_LEGACY: "会员服务尚未启用,当前按原有方式运行",
}[self.state]
def check_status(
config=None,
*,
cmhub_config_path=None,
request_json=None,
) -> SubscriptionStatus:
"""Look up the current API-key account subscription without leaking secrets.
A service that has not yet deployed the endpoint returns ``404``. That is
an explicit rollout compatibility state rather than an invalid membership.
"""
cfg = appconfig.load_config() if config is None else config
try:
cmhub = appconfig.cmhub_config(cfg)
base_url = appconfig.normalize_cmhub_base_url(cmhub.get("base_url"))
key_path = (
cmhub_config_path
or cfg.get("cmhub_config_path")
or appconfig.cmhub_config_file_path(cfg)
)
api_key = appconfig.get_cmhub_api_key(path=key_path)
except Exception:
return SubscriptionStatus(STATUS_NOT_CONFIGURED)
if not base_url or not api_key:
return SubscriptionStatus(STATUS_NOT_CONFIGURED)
requester = request_json or ai.request_cmhub_json
try:
data = requester(
"GET",
base_url,
"/api/v1/cmshopee/subscription/status",
api_key,
connect_timeout=cmhub.get(
"connect_timeout",
appconfig.CMHUB_CONNECT_TIMEOUT_DEFAULT,
),
read_timeout=30,
use_system_proxy=bool(appconfig.ai_config(cfg).get("use_system_proxy")),
)
except ai.CMHubError as exc:
return _status_from_cmhub_error(exc)
except Exception:
return SubscriptionStatus(STATUS_UNAVAILABLE)
return _parse_status_response(data, base_url)
def format_expiry(value: str) -> str:
"""Return a compact local date for the title-area membership label."""
text = str(value or "").strip()
if not text:
return ""
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
return ""
return parsed.date().isoformat()
def _status_from_cmhub_error(exc: ai.CMHubError) -> SubscriptionStatus:
code = str(getattr(exc, "code", "") or "").strip().lower()
if code == "not_found":
return SubscriptionStatus(STATUS_LEGACY)
if code == "unauthorized":
return SubscriptionStatus(STATUS_KEY_INVALID)
if code == "account_disabled":
return SubscriptionStatus(STATUS_ACCOUNT_DISABLED)
if code in {"subscription_required", "subscription_missing"}:
return SubscriptionStatus(STATUS_REQUIRED)
if code in {"subscription_expired", "license_expired"}:
return SubscriptionStatus(STATUS_EXPIRED)
if code in {"subscription_revoked", "subscription_cancelled"}:
return SubscriptionStatus(STATUS_REVOKED)
return SubscriptionStatus(STATUS_UNAVAILABLE)
def _parse_status_response(data, base_url: str) -> SubscriptionStatus:
if not isinstance(data, dict):
return SubscriptionStatus(STATUS_UNAVAILABLE)
if str(data.get("product_code") or "").strip().lower() != PRODUCT_CODE:
return SubscriptionStatus(STATUS_UNAVAILABLE)
normalized = _normalize_remote_state(data.get("status"))
if not normalized:
return SubscriptionStatus(STATUS_UNAVAILABLE)
account = data.get("account") if isinstance(data.get("account"), dict) else {}
plan = data.get("plan") if isinstance(data.get("plan"), dict) else {}
account_name = str(account.get("display_name") or "").strip()
plan_name = str(plan.get("display_name") or "").strip()
expires_at = str(data.get("expires_at") or "").strip()
grace_expires_at = str(data.get("grace_expires_at") or "").strip()
manage_url = _safe_manage_url(data.get("manage_url"), base_url)
notice_id = str(data.get("notice_id") or "").strip()
if normalized in {STATUS_ACTIVE, STATUS_GRACE}:
if not account_name or not plan_name or not format_expiry(expires_at):
return SubscriptionStatus(STATUS_UNAVAILABLE)
if (
normalized == STATUS_GRACE
and grace_expires_at
and not format_expiry(grace_expires_at)
):
return SubscriptionStatus(STATUS_UNAVAILABLE)
return SubscriptionStatus(
normalized,
account_name=account_name,
plan_name=plan_name,
expires_at=expires_at,
grace_expires_at=grace_expires_at,
manage_url=manage_url,
notice_id=notice_id,
)
def _normalize_remote_state(value) -> str:
normalized = str(value or "").strip().lower().replace("-", "_")
return {
"active": STATUS_ACTIVE,
"grace": STATUS_GRACE,
"required": STATUS_REQUIRED,
"subscription_required": STATUS_REQUIRED,
"expired": STATUS_EXPIRED,
"subscription_expired": STATUS_EXPIRED,
"revoked": STATUS_REVOKED,
"cancelled": STATUS_REVOKED,
"subscription_revoked": STATUS_REVOKED,
"account_disabled": STATUS_ACCOUNT_DISABLED,
}.get(normalized, "")
def _safe_manage_url(value, base_url: str) -> str:
text = str(value or "").strip()
if not text:
return ""
parsed = urlsplit(text)
base = urlsplit(str(base_url or ""))
if parsed.scheme != "https" or not parsed.hostname:
return ""
if (
parsed.username
or parsed.password
or not base.hostname
or parsed.hostname.lower() != base.hostname.lower()
):
return ""
return text
+3
View File
@@ -25,6 +25,7 @@ GUI(PySide6 QTabWidget,当前显示 6 Tab)
├── cdp CDP 客户端(连接、找/开 tab、执行 JS、拖拽)
├── editor 登录检测 / 检测商品状态 / 采集旧标题旧封面 / 改标题 / 换封面 / 点更新
├── product_status 商品状态代码、显示和分组规则
├── subscription cmhub 账号订阅状态查询、响应归一化与客户端预检状态
├── ai 文本生成(提示词+旧标题→新标题)/ 图像生成(提示词+旧封面→新封面)/ 商品原图理解(商品套图AI帮写)
├── image_studio 商品套图项目/资产/job数据服务(兼容旧AI工场终选)
├── product_suite 套图设置归一化、数量计算、完整提示词与job规划
@@ -78,6 +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,服务端始终是最终授权方。
- `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、日志或导出文件。
@@ -86,6 +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` 表示服务端尚未启用订阅接口,客户端保持旧行为;网络异常不被误判为会员到期。
- cmhub 模型目录与 AI帮写/正式套图预估价格 → 仅内存短期缓存;预估值只供用户确认,实际扣点仍以网关响应 metadata 为准。
- 业务数据(账号、任务、各阶段结果)→ SQLite `data/cmshopee.db`。
- 图片(采集的旧封面、AI 生成的新封面)→ `data/images/`(路径记在 DB)。
+12 -1
View File
@@ -25,6 +25,14 @@
创建六个业务Tab之前先请求版本接口。服务端明确要求强制升级时,不创建 `MainWindow`,而是显示「必须升级」模态进度窗口:用户点击「立即升级」后可看到下载、校验、准备新版和重启阶段,以及百分比和字节数;运行中可「取消并退出」,失败后可重试。校验完成后软件启动安装目录外的独立更新器并退出,更新器替换程序后自动重启新版。版本接口完全不可达或非法时仍失败放行;一旦已明确强制,元数据缺失或后续失败都不允许进入旧版主界面。
## 会员订阅接入
- 强制升级检查完成、主窗口显示后,后台用 `data/config/cmhub.json` 的 API Key 请求 `GET /api/v1/cmshopee/subscription/status`;不会阻塞 Qt 主线程或把 Key 放入 URL、状态栏、日志和错误提示。
- 状态接口已启用时,未配置 Key、Key 无效、账号禁用、未订阅、到期、撤销或暂时无法确认会员状态,主窗口只保留「设置」Tab,并在顶部显示中文原因;用户可打开设置重填 Key、按服务端返回的 `manage_url` 用默认浏览器前往线上会员中心,或退出。已接受的异步任务、本地数据和导出不删除。
- 订阅有效时,应用顶部标题区域右侧显示“账号名 · 套餐名 · 有效至日期”;宽限期显示宽限截止日。不得把长会员文本拼进 Windows 原生窗口标题,也不得展示 API Key。
- 当前服务端未部署订阅接口时,`404` 视为旧服务兼容:顶部显示“会员服务尚未启用”,六个工作流继续按旧行为运行,不弹阻断窗口。网络或格式错误显示“暂时无法确认会员状态”,不能误报为 Key 无效或会员到期。
- ② AI生成、⑥商品套图和「AI帮写」的每次新提交均读取同一订阅预检状态;客户端预检只改善体验,cmhub 产品接口仍必须服务端最终裁决。已提交的异步任务继续查询、下载和查看。自定义网关直连同样做客户端软校验,不能作为不可绕过的授权保护。
## 全局 Tab 栏可用性
当前 6 个主 Tab 是高频导航入口,不能使用 Qt 默认的紧凑宽度。`MainWindow` 必须为 `QTabWidget/QTabBar` 设置基础样式:
@@ -110,6 +118,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`,引导到⑤设置或线上会员中心;旧服务订阅接口尚未启用时保持原有生成行为。
- 生成参数(标题/图片并发数、失败重试、分辨率、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,默认不删除本地新封面文件。若范围内包含已提交线上记录,必须提示本地重置不回滚蝦皮,重生成后再更新会再次提交线上。
@@ -223,6 +232,7 @@
- AI帮写提交图片理解前先显示「开始AI帮写」确认框:按 `source_order` 说明会理解当前商品前1至8张可用原图并生成商品卖点与要求。模型目录只走后台读取或进程内短期缓存;仅当前图片理解别名有唯一无条件价格时显示「预计扣点:X 点」,否则明确实际以网关返回为准。确认框默认、Esc 和关闭均取消,不提交图片;开始后可取消本地等待,但已提交网关的请求仍可能产生扣点。预估不写入业务数据,完成后仍只显示接口返回的实际扣点和余额。
- 常规「生成套图」保留“已有成功历史”优先确认,选择继续后才后台读取或复用同一模型目录缓存,并显示正式生成确认。确认严格按最终 planned `specs` 展示各分类实际张数、总张数和比例;逐图主图开启时明确白底图只用第一张原图,其他分类按每张原图生成;关闭时所有分类使用第1张主图及同一批冻结参考图。仅唯一无条件的生图价格显示预计单张和总扣点,总价只按 `len(specs)` 计算;价格未知时不显示数字。默认、Esc、关闭、切换任务、取消读取或计划变化均不创建生图 job;单图失败重试和恢复未完成任务不增加这一层批量确认。
- 商品套图只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。
- 「AI帮写」和「生成套图」在新提交前共用主窗口会员预检;默认网关或自定义网关直连都一样。继续查询已提交默认网关任务、下载、预览、历史查看和导出不属于新提交,不因会员状态变化中断。
## 流程导航
@@ -246,7 +256,7 @@
| 组件 | 归属 | 说明 |
| --- | --- | --- |
| `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、状态栏、全局消息 |
| `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、状态栏、顶部会员状态、订阅受限恢复入口和全局消息 |
| `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 |
| `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;先确认商品状态生成范围,再按本轮「生成内容」下拉接入 `GenerateWorker` |
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 商品状态优先/内容完整性预检剔除 +「检查本轮更新」+ 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 |
@@ -257,6 +267,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 回主线程更新会员标签和工作流可用性 |
| `WriteBackWorker(BaseWorker)` | ①③ | ①回写旧字段;③回写新标题/新封面/更新状态 |
| `ImageStudioPullImagesWorker / ImageStudioDownloadOriginalWorker / ProductSuiteImportImagesWorker / ProductSuiteGenerateWorker / ProductSuiteAiWriteWorker / CMHubModelCatalogWorker` | 商品套图 | 后台执行只读拉主图、远程原图下载、本地图片校验复制、默认网关异步或自定义网关同步套图生成、AI帮写和只读模型目录;拉图和本轮下载支持安全边界协作停止,worker 不直接操作 QWidget |
+7 -2
View File
@@ -3,7 +3,7 @@ id: T-686
title: 会员订阅状态接入与启动访问门禁
phase: 8
deps: []
status: TODO
status: DONE
created: 2026-07-21
---
@@ -59,7 +59,10 @@ cmhub 需提供 `GET /api/v1/cmshopee/subscription/status`。2026-07-21 无凭
## 验证
```bash
py -3.10 -m unittest tests.test_subscription tests.test_ai tests.test_image_studio_generation tests.test_gui
py -3.10 -m unittest discover -s tests -p test_subscription.py
py -3.10 -m unittest discover -s tests -p test_ai.py
py -3.10 -m unittest discover -s tests -p test_image_studio_generation.py
py -3.10 -m unittest discover -s tests -p test_gui.py
py -3.10 -m unittest discover -s tests
py -3.10 -m ruff check app tests main.py
py -3.10 -m compileall app main.py
@@ -82,3 +85,5 @@ git diff --check
## 执行记录
- 2026-07-21:任务创建;cmhub 订阅状态接口尚未上线,需在实现中保持 `404` 旧服务兼容。
- 2026-07-21:新增 `app/subscription.py` 和 `SubscriptionCheckWorker`,复用 cmhub 共享 HTTP 会话查询订阅状态;主窗口在显示后异步检查,顶部显示账号/套餐/有效期,受限状态只保留设置恢复入口。② AI生成、⑥商品套图和 AI帮写的新提交接入同一预检,旧服务 `404` 放行既有工作流。
- 2026-07-21:验证通过:`py -3.10 -m compileall app main.py`、`py -3.10 -m ruff check app tests main.py`、订阅/应用配置/AI/套图/GUI 定向测试,以及 `py -3.10 -m unittest discover -s tests`(664 项)。无凭据请求线上订阅状态接口返回 `404`,已由单测和运行时兼容分支覆盖;服务端上线后需使用测试 API Key 做一次真实有效/到期/未订阅联调,不属于本仓库可完成的接口发布工作。
+23
View File
@@ -480,6 +480,29 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
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")
loaded = appconfig.load_config(config_path)
self.assertEqual("", appconfig.subscription_notice_id(loaded))
saved = appconfig.save_subscription_notice_id(
"subscription-notice-20260721",
path=config_path,
)
self.assertEqual(
"subscription-notice-20260721",
appconfig.subscription_notice_id(saved),
)
reloaded = appconfig.load_config(config_path)
self.assertEqual(
"subscription-notice-20260721",
appconfig.subscription_notice_id(reloaded),
)
self.assert_removed(temp_dir)
def test_cmhub_base_url_normalizes_to_gateway_root(self):
cases = {
"https://cmhub.example.com/": "https://cmhub.example.com",
+57
View File
@@ -22,6 +22,7 @@ from app import (
image_studio,
product_status,
prompts,
subscription,
update_check,
update_installer,
)
@@ -11154,6 +11155,62 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
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))
self.addCleanup(window.close)
status = subscription.SubscriptionStatus(
subscription.STATUS_ACTIVE,
account_name="主账号",
plan_name="专业版",
expires_at="2026-08-20T23:59:59+08:00",
)
window._apply_subscription_status(status, show_prompt=False)
self.assertIn("主账号 · 专业版 · 有效至2026-08-20", window._subscription_label.text())
self.assertTrue(
all(window.tabs.isTabEnabled(index) for index in range(window.tabs.count()))
)
def test_main_window_restricts_tabs_when_subscription_is_invalid(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_EXPIRED)
window._apply_subscription_status(status, show_prompt=False)
for index, title in enumerate(TAB_TITLES):
self.assertEqual(title == "设置", window.tabs.isTabEnabled(index))
self.assertEqual(TAB_TITLES.index("设置"), window.tabs.currentIndex())
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)
actions = []
def deny(action):
actions.append(action)
return False
generate_tab = GenerateTab(
config=config,
subscription_preflight_callback=deny,
)
self.addCleanup(generate_tab.close)
generate_tab.start_generate()
suite_tab = ProductSuiteTab(
config=config,
db_path=config["db_path"],
subscription_preflight_callback=deny,
)
self.addCleanup(suite_tab.close)
self.assertFalse(suite_tab.start_generation(None))
self.assertEqual(["开始 AI 生成", "生成商品套图"], actions)
def test_accounts_tab_launch_failure_restores_controls(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
+156
View File
@@ -0,0 +1,156 @@
import os
import unittest
from _helpers import TempDirMixin
from app import ai, appconfig, subscription
class SubscriptionTests(TempDirMixin, unittest.TestCase):
def _config(self, temp_dir):
cmhub_path = os.path.join(temp_dir, "cmhub.json")
appconfig.save_cmhub_config({"api_key": "test-key"}, path=cmhub_path)
return {
"cmhub_config_path": cmhub_path,
"ai": {
"use_system_proxy": False,
"cmhub": {
"base_url": "https://cm.example.com",
"connect_timeout": 66,
},
},
}
def test_missing_key_requires_configuration(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"])
result = subscription.check_status(config)
self.assertEqual(subscription.STATUS_NOT_CONFIGURED, result.state)
self.assertFalse(result.allows_product_workflows)
def test_active_response_returns_safe_display_fields(self):
with self.make_temp_dir() as temp_dir:
calls = []
def request_json(*args, **kwargs):
calls.append((args, kwargs))
return {
"product_code": "cmshopee",
"account": {"display_name": "主账号"},
"plan": {"code": "pro", "display_name": "专业版"},
"status": "active",
"expires_at": "2026-08-20T23:59:59+08:00",
"grace_expires_at": None,
"manage_url": "https://cm.example.com/user/subscriptions/cmshopee",
"notice_id": "notice-1",
}
result = subscription.check_status(
self._config(temp_dir),
request_json=request_json,
)
self.assertEqual(subscription.STATUS_ACTIVE, result.state)
self.assertTrue(result.allows_product_workflows)
self.assertEqual("主账号", result.account_name)
self.assertEqual("专业版", result.plan_name)
self.assertEqual("2026-08-20", subscription.format_expiry(result.expires_at))
self.assertEqual(
"https://cm.example.com/user/subscriptions/cmshopee",
result.manage_url,
)
self.assertEqual("GET", calls[0][0][0])
self.assertNotIn("test-key", repr(result))
def test_grace_and_expired_states_are_distinct(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
def grace_request(*args, **kwargs):
return {
"product_code": "cmshopee",
"account": {"display_name": "主账号"},
"plan": {"display_name": "专业版"},
"status": "grace",
"expires_at": "2026-08-20T23:59:59+08:00",
"grace_expires_at": "2026-08-23T23:59:59+08:00",
}
grace = subscription.check_status(config, request_json=grace_request)
self.assertEqual(subscription.STATUS_GRACE, grace.state)
self.assertTrue(grace.allows_product_workflows)
def expired_request(*args, **kwargs):
return {
"product_code": "cmshopee",
"account": {"display_name": "主账号"},
"plan": {"display_name": "专业版"},
"status": "expired",
"expires_at": "2026-08-20T23:59:59+08:00",
}
expired = subscription.check_status(config, request_json=expired_request)
self.assertEqual(subscription.STATUS_EXPIRED, expired.state)
self.assertFalse(expired.allows_product_workflows)
def test_legacy_404_keeps_existing_workflows_available(self):
with self.make_temp_dir() as temp_dir:
def request_json(*args, **kwargs):
raise ai.CMHubError("not_found", "接口不存在", status=404)
result = subscription.check_status(
self._config(temp_dir),
request_json=request_json,
)
self.assertEqual(subscription.STATUS_LEGACY, result.state)
self.assertTrue(result.allows_product_workflows)
self.assertFalse(result.interface_available)
def test_auth_error_and_network_failure_have_different_states(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
def invalid_key(*args, **kwargs):
raise ai.CMHubError("unauthorized", "不应展示", status=401)
result = subscription.check_status(config, request_json=invalid_key)
self.assertEqual(subscription.STATUS_KEY_INVALID, result.state)
self.assertNotIn("不应展示", result.user_message)
def network_failure(*args, **kwargs):
raise ai.CMHubError("network_error", "不应展示")
result = subscription.check_status(config, request_json=network_failure)
self.assertEqual(subscription.STATUS_UNAVAILABLE, result.state)
self.assertNotIn("不应展示", result.user_message)
def test_invalid_product_or_external_manage_url_is_not_trusted(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
def wrong_product(*args, **kwargs):
return {"product_code": "another-product", "status": "active"}
result = subscription.check_status(config, request_json=wrong_product)
self.assertEqual(subscription.STATUS_UNAVAILABLE, result.state)
def external_manage_url(*args, **kwargs):
return {
"product_code": "cmshopee",
"account": {"display_name": "主账号"},
"plan": {"display_name": "专业版"},
"status": "required",
"manage_url": "https://other.example.com/account",
}
result = subscription.check_status(config, request_json=external_manage_url)
self.assertEqual(subscription.STATUS_REQUIRED, result.state)
self.assertEqual("", result.manage_url)
if __name__ == "__main__":
unittest.main()