Files
GVSDSDK/msync/client.py
T
TermiNexus e9bde4db76 "chore(gvsdsdk): 清理过时点与死代码并修复运行期缺陷
- models: 修正过时文档、UserRole 遗留字段标注废弃、抽象 User 补 IsStaff/IsSuperuser
- models: CopyToRole 补 CompanyUUID 默认值,修复 RolePermission 插入必失败缺陷
- form_service: 对齐主项目值转换(Boolean/时区/空值),移除 NullBooleanField 遗留映射
- auth: 删除无引用的 GetDefaultAvatar 与 ShopManager/PositionManager,清理惰性导出
- db_router/model_base/msync: 简化模块集合、移除 typing_extensions 死兜底、复用 GenerateChallenge
- 删除残留文件(空测试文件、无源 pyc)"
2026-08-07 00:57:29 +08:00

303 lines
10 KiB
Python

"""MSYNC 服务客户端模块
提供 ServiceClient 类,用于微服务间发起认证请求。
支持自动令牌获取与刷新、OGM 解析、服务发现。
本模块自包含实现(使用 urllib),不依赖 Django。
"""
import json
import time
import uuid
import logging
import urllib.request
import urllib.error
from urllib.parse import quote
logger = logging.getLogger(__name__)
MSYNC_AUTH_PATH = '/api/msync/auth/token/'
MSYNC_AUTH_REFRESH_PATH = '/api/msync/auth/token/refresh/'
MSYNC_OGM_RESOLVE_PATH = '/api/msync/ogm/{global_id}/'
MSYNC_REGISTRY_PATH = '/api/msync/registry/{name}/'
#: 默认 MasterURL(仅当 settings 与环境变量均未配置时使用)
DEFAULT_MASTER_URL = 'https://api.gvsds.com'
from gvsdsdk.msync.auth import SignPayload, BuildAuthPayload
def _ResolveMasterURL(master_url):
"""解析 MasterURL:优先参数 → Django settings → 环境变量 → 默认值
本模块自包含(不依赖 Django ORM/HTTP),但允许通过 settings 或环境变量覆盖默认地址。
"""
if master_url:
return master_url.rstrip('/')
# 尝试 Django settings(软依赖,非 Django 环境下跳过)
try:
from django.conf import settings as _settings
url = getattr(_settings, 'GVSDSDK_MSYNC_MASTER_URL', None)
if url:
return str(url).rstrip('/')
except Exception:
pass
# 回退到环境变量
import os as _os
url = _os.environ.get('GVSDSDK_MSYNC_MASTER_URL')
if url:
return url.rstrip('/')
return DEFAULT_MASTER_URL
class ServiceClientError(Exception):
def __init__(self, Message, StatusCode=None, ResponseBody=None):
super().__init__(Message)
self.StatusCode = StatusCode
self.ResponseBody = ResponseBody
class _TokenStore:
"""内存中的令牌存储"""
def __init__(self):
self._AccessToken = None
self._RefreshToken = None
self._ExpiresAt = 0.0
self._Scope = []
@property
def AccessToken(self):
return self._AccessToken
@property
def RefreshToken(self):
return self._RefreshToken
@property
def Scope(self):
return self._Scope
def IsValid(self):
return self._AccessToken is not None and time.time() < self._ExpiresAt
def Store(self, AccessToken, RefreshToken, ExpiresAt, Scope=None):
self._AccessToken = AccessToken
self._RefreshToken = RefreshToken
self._ExpiresAt = ExpiresAt
self._Scope = Scope or []
def Clear(self):
self._AccessToken = None
self._RefreshToken = None
self._ExpiresAt = 0.0
self._Scope = []
class ServiceClient:
"""微服务通信客户端——用于向其他服务发起认证请求
用法:
client = ServiceClient(
ServiceName='gerp',
ServiceSecret='your-secret',
)
client.Authenticate()
# 获取其他服务信息
info = client.DiscoverService('crb')
# 解析 OGM
ogm = client.ResolveOGM('user:abc123')
# 向其他服务发请求
response = client.Request('GET', 'https://crb.gvsds.com/api/wallet', Scope=['ledger:read'])
"""
def __init__(self, ServiceName, ServiceSecret, MasterURL=None):
self.ServiceName = ServiceName
self.ServiceSecret = ServiceSecret
self.MasterURL = _ResolveMasterURL(MasterURL)
self._tokens = _TokenStore()
@property
def IsAuthenticated(self):
return self._tokens.IsValid()
@property
def AccessToken(self):
return self._tokens.AccessToken
@property
def RefreshToken(self):
return self._tokens.RefreshToken
def Authenticate(self):
if not self._Authenticate():
raise ServiceClientError('认证失败')
return self
def Refresh(self):
if not self._RefreshToken():
raise ServiceClientError('刷新失败')
return self
def _Authenticate(self):
challenge = _GenerateChallenge()
payload = BuildAuthPayload(self.ServiceName, challenge)
signature = SignPayload(payload, self.ServiceSecret)
req_body = json.dumps({
'ServiceName': self.ServiceName,
'Payload': payload,
'Signature': signature,
}).encode('utf-8')
req = urllib.request.Request(
f'{self.MasterURL}{MSYNC_AUTH_PATH}',
data=req_body,
headers={'Content-Type': 'application/json'},
method='POST',
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode('utf-8'))
except urllib.error.HTTPError as e:
body = e.read().decode('utf-8', errors='replace')
logger.error(f'认证失败 HTTP {e.code}: {body[:200]}') # 限制日志长度,避免泄漏完整 token
return False
except (urllib.error.URLError, ValueError, OSError) as e:
logger.error(f'认证异常: {e}')
return False
if data.get('code') not in (200, 0):
logger.error(f'认证业务错误 code={data.get("code")} msg={data.get("msg", "")[:200]}') # 仅记录 code 和简短消息
return False
token_data = data.get('data') or {}
self._tokens.Store(
AccessToken=token_data.get('AccessToken'),
RefreshToken=token_data.get('RefreshToken'),
ExpiresAt=token_data.get('ExpiresAt', 0),
Scope=token_data.get('Scope', []),
)
return self._tokens.AccessToken is not None
def _RefreshToken(self):
if not self._tokens.RefreshToken:
return False
req_body = json.dumps({
'ServiceName': self.ServiceName,
'RefreshToken': self._tokens.RefreshToken,
}).encode('utf-8')
req = urllib.request.Request(
f'{self.MasterURL}{MSYNC_AUTH_REFRESH_PATH}',
data=req_body,
headers={'Content-Type': 'application/json'},
method='POST',
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode('utf-8'))
except (urllib.error.URLError, ValueError, OSError) as e:
logger.error(f'刷新令牌异常: {e}')
return False
if data.get('code') not in (200, 0):
return False
token_data = data.get('data') or {}
self._tokens.Store(
AccessToken=token_data.get('AccessToken'),
RefreshToken=token_data.get('RefreshToken'),
ExpiresAt=token_data.get('ExpiresAt', 0),
Scope=token_data.get('Scope', []),
)
return self._tokens.AccessToken is not None
def _EnsureAuthenticated(self):
if self.IsAuthenticated:
return
if self._tokens.RefreshToken:
self._RefreshToken()
return
self._Authenticate()
# 注:本方法约 60 行且含 3 层 try/except 嵌套(M9)。
# 未拆分原因:401 重试逻辑与首次请求共享 req_headers/body/URL/Method/Timeout 等局部状态,
# 拆分为独立方法需传递大量参数或改用闭包,收益有限且可能影响现有测试覆盖,故保持原结构。
def Request(self, Method, URL, Data=None, Headers=None, Scope=None, Timeout=30):
self._EnsureAuthenticated()
req_headers = {
'Authorization': f'Service {self.AccessToken}',
'Content-Type': 'application/json',
'X-Source-Service': self.ServiceName,
}
if Scope:
req_headers['X-Required-Scope'] = ','.join(Scope)
if Headers:
req_headers.update(Headers)
body = None
if Data is not None:
if isinstance(Data, dict):
body = json.dumps(Data).encode('utf-8')
elif isinstance(Data, bytes):
body = Data
elif isinstance(Data, str):
body = Data.encode('utf-8')
req = urllib.request.Request(URL, data=body, headers=req_headers, method=Method)
try:
with urllib.request.urlopen(req, timeout=Timeout) as resp:
response_data = resp.read().decode('utf-8')
content_type = resp.headers.get('Content-Type', '')
if 'application/json' in content_type:
return json.loads(response_data)
return response_data
except urllib.error.HTTPError as e:
body_text = e.read().decode('utf-8', errors='replace')
if e.code == 401:
logger.info('令牌可能已失效,尝试刷新')
self._tokens.Clear()
self._EnsureAuthenticated()
if not self.AccessToken:
raise ServiceClientError('重新认证失败,无法重试请求')
req_headers['Authorization'] = f'Service {self.AccessToken}'
retry_req = urllib.request.Request(URL, data=body, headers=req_headers, method=Method)
try:
with urllib.request.urlopen(retry_req, timeout=Timeout) as resp2:
response_data = resp2.read().decode('utf-8')
content_type = resp2.headers.get('Content-Type', '')
if 'application/json' in content_type:
return json.loads(response_data)
return response_data
except urllib.error.HTTPError as e2:
body2 = e2.read().decode('utf-8', errors='replace')
raise ServiceClientError(
f'重试后仍失败: HTTP {e2.code}',
StatusCode=e2.code,
ResponseBody=body2,
)
raise ServiceClientError(
f'请求失败: HTTP {e.code}',
StatusCode=e.code,
ResponseBody=body_text,
)
def ResolveOGM(self, GlobalID):
url = f'{self.MasterURL}{MSYNC_OGM_RESOLVE_PATH.format(global_id=quote(str(GlobalID), safe=""))}'
return self.Request('GET', url, Scope=['ogm:read'])
def DiscoverService(self, ServiceName):
url = f'{self.MasterURL}{MSYNC_REGISTRY_PATH.format(name=quote(str(ServiceName), safe=""))}'
return self.Request('GET', url, Scope=['registry:read'])