SubscriptionClient 新增 Ping() 与 DeclareCustomPage():提供者子服务器用「产品 ApiKey+密钥」换令牌后,调用主服务器 Provider/Page/Save 声明本产品定制页的远端入口(供消费者「我的订阅」打开)。
166 lines
6.6 KiB
Python
166 lines
6.6 KiB
Python
"""订阅式软件提供——提供者子服务器客户端
|
||
|
||
用「软件(产品)的 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
|