Files
TermiNexus 8b7800032d feat(subscription): 订阅客户端增加声明定制页与鉴权探测
SubscriptionClient 新增 Ping() 与 DeclareCustomPage():提供者子服务器用「产品 ApiKey+密钥」换令牌后,调用主服务器 Provider/Page/Save 声明本产品定制页的远端入口(供消费者「我的订阅」打开)。
2026-10-05 17:01:49 +08:00

166 lines
6.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""订阅式软件提供——提供者子服务器客户端
用「软件(产品)的 API Key + 密钥」向主服务器换取访问令牌,并携带令牌调用订阅接口。
仅依赖标准库(urllib),便于子服务器单独引用;不依赖 Django ORM。
用法:
client = SubscriptionClient(MasterURL='https://gerp.gvsds.com',
ApiKey='sk_xxx', Secret='密钥明文(仅激活/重置时展示一次)')
token = client.FetchToken()['AccessToken']
data = client.Request('GET', '/API/Subscription/Provider/Ping')
"""
import json
import logging
import time
import urllib.error
import urllib.parse
import urllib.request
logger = logging.getLogger(__name__)
#: 令牌到期前的提前续期余量(秒)
TOKEN_REFRESH_SKEW_SECONDS = 60
#: 默认请求超时(秒)
DEFAULT_TIMEOUT = 10
def _ResolveMasterURL(MasterURL=None):
"""解析主服务器地址(复用 msync 的解析链:参数 → settings → 环境变量 → 默认值)"""
from gvsdsdk.msync.client import _ResolveMasterURL as _MsyncResolveMasterURL
return _MsyncResolveMasterURL(MasterURL)
class SubscriptionClientError(Exception):
"""订阅客户端错误(携带 HTTP 状态码与响应体,便于上层定位)"""
def __init__(self, Message, StatusCode=None, ResponseBody=None):
super().__init__(Message)
self.StatusCode = StatusCode
self.ResponseBody = ResponseBody
class SubscriptionClient:
"""提供者子服务器侧客户端
Args:
ApiKey: 软件的 API Key
Secret: 软件的密钥(明文,仅在激活/重置时展示一次)
MasterURL: 主服务器地址(缺省走 msync 的解析链)
Timeout: 请求超时秒数
"""
def __init__(self, ApiKey, Secret, MasterURL=None, Timeout=DEFAULT_TIMEOUT):
if not ApiKey or not Secret:
raise ValueError('SubscriptionClient 必须提供 ApiKey 与 Secret')
self.ApiKey = ApiKey
self.Secret = Secret
self.MasterURL = _ResolveMasterURL(MasterURL)
self.Timeout = Timeout
self._AccessToken = None
self._ExpiresAt = 0.0
@property
def AccessToken(self):
return self._AccessToken
@property
def IsTokenValid(self):
return bool(self._AccessToken) and time.time() < (self._ExpiresAt - TOKEN_REFRESH_SKEW_SECONDS)
def FetchToken(self):
"""用 ApiKey + 密钥换取访问令牌(成功后写入内存缓存)
Returns:
dict:{'AccessToken', 'ExpiresAt', 'SoftwareUUID', 'GroupName'}
"""
payload = self._HttpRequest(
'POST', '/API/Subscription/Provider/Token',
Json={'ApiKey': self.ApiKey, 'Secret': self.Secret},
)
data = payload.get('data') or {}
token = data.get('AccessToken')
if not token:
raise SubscriptionClientError('换取令牌失败:响应缺少 AccessToken', ResponseBody=payload)
self._AccessToken = token
self._ExpiresAt = float(data.get('ExpiresAt') or 0) or (time.time() + 3600)
return data
def EnsureToken(self):
"""确保有可用令牌(无效则自动换取)"""
if not self.IsTokenValid:
self.FetchToken()
return self._AccessToken
def Request(self, Method, Path, Json=None, Params=None):
"""携带访问令牌调用主服务器接口
Args:
Method: HTTP 方法(GET/POST/...)
Path: 接口路径(如 /API/Subscription/Provider/Ping)
Json: 请求体(POST 用)
Params: 查询参数(dict)
Returns:
dict:主服务器统一响应体 {code, msg, data}
"""
token = self.EnsureToken()
headers = {'Authorization': f'Bearer {token}'}
return self._HttpRequest(Method, Path, Json=Json, Params=Params, Headers=headers)
# ── 提供者子服务器业务操作(ApiKey + 密钥 换取令牌后调用) ──────────
def Ping(self):
"""鉴权连通性探测(验证 ApiKey+密钥 可正常换取令牌并访问提供者接口)"""
return self._CallProvider('GET', '/API/Subscription/Provider/Ping')
def DeclareCustomPage(self, RemoteEntry, RemoteModule='default'):
"""声明本产品定制页的远端入口(幂等覆盖)
消费者在「我的订阅」里打开该产品时,GERP 宿主前端按 RemoteEntry 运行时
加载远端组件并原生渲染(微服务前端;跨源需该 URL 提供 CORS)。
Returns:
dict:{'GroupUUID', 'GroupName', 'RemoteEntry', 'RemoteModule'}
"""
return self._CallProvider(
'POST', '/API/Subscription/Provider/Page/Save',
Json={'RemoteEntry': RemoteEntry, 'RemoteModule': RemoteModule},
)
def _CallProvider(self, Method, Path, Json=None):
"""调用提供者接口并解包 {code, msg, data}(code!=0 抛 SubscriptionClientError)"""
payload = self.Request(Method, Path, Json=Json)
if not isinstance(payload, dict) or payload.get('code') != 0:
msg = (payload or {}).get('msg') if isinstance(payload, dict) else ''
raise SubscriptionClientError(msg or '主服务器返回失败', ResponseBody=payload)
return payload.get('data')
# ── 内部:HTTP 收发 ────────────────────────────────────────────
def _HttpRequest(self, Method, Path, Json=None, Params=None, Headers=None):
url = f'{self.MasterURL}{Path}'
if Params:
url = f'{url}?{urllib.parse.urlencode(Params)}'
body = None
headers = dict(Headers or {})
if Json is not None:
body = json.dumps(Json).encode('utf-8')
headers['Content-Type'] = 'application/json'
request = urllib.request.Request(url, data=body, headers=headers, method=Method.upper())
try:
with urllib.request.urlopen(request, timeout=self.Timeout) as response:
raw = response.read().decode('utf-8') or '{}'
except urllib.error.HTTPError as e:
raw = e.read().decode('utf-8', errors='replace')
raise SubscriptionClientError(
f'主服务器返回 {e.code}', StatusCode=e.code, ResponseBody=raw,
) from e
except urllib.error.URLError as e:
raise SubscriptionClientError(f'无法连接主服务器:{e.reason}') from e
try:
return json.loads(raw)
except json.JSONDecodeError as e:
raise SubscriptionClientError('主服务器响应不是合法 JSON', ResponseBody=raw) from e