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
+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