feat(msync): ATestCServer 代码入库(忽略 certs/db.sqlite3)+ 远端 App 清单上报能力(ReportAppManifests + report_manifest 命令)

This commit is contained in:
TermiNexus committed 2026-08-13 23:59:34 +08:00
1 parent 6e16c0d3b7
commit fda236eb0c
28 files changed
+2206 -2

No files matched your search

+3 -2
View File
@@ -12,8 +12,9 @@
/static/
/staticfiles/
# 子服务器示例(含 HTTPS 私钥与 SQLite 数据,防入库)
/ATestCServer/
# ATestCServer:代码入库,仅忽略敏感文件(HTTPS 私钥/本地 SQLite)
/ATestCServer/certs/
/ATestCServer/db.sqlite3
# 用户上传目录
/media/
Whitespace-only changes.
+5
View File
@@ -0,0 +1,5 @@
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ATestCServer.settings')
application = get_asgi_application()
+153
View File
@@ -0,0 +1,153 @@
"""ATestCServer — SMANAGE 子服务器
通过 MSYNC 协议与 SMANAGE 主服务器(www.gvsds.com)桥接。
通信拓扑:
子 → 父: https://www.gvsds.com/api/msync/* (MSYNC 协议)
父 → 子: http://127.0.0.1:25080/api/sub/* (本地回环)"""
import os
from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')
if not SECRET_KEY:
raise ImproperlyConfigured('DJANGO_SECRET_KEY 环境变量未设置,禁止使用默认值启动')
DEBUG = os.environ.get('DJANGO_DEBUG', 'False').lower() in ('true', '1', 'yes')
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'gvsdsdk',
'apps.sub_server',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'ATestCServer.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'ATestCServer.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': os.environ.get('DB_NAME', 'atestc'),
'USER': os.environ.get('DB_USER', 'root'),
'PASSWORD': os.environ.get('DB_PASSWORD', ''),
'HOST': os.environ.get('DB_HOST', '127.0.0.1'),
'PORT': os.environ.get('DB_PORT', '3306'),
'OPTIONS': {
'charset': 'utf8mb4',
'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
},
},
}
DATABASE_ROUTERS = []
# gvsdsdk 表由 ATestCServer 自己管理(同源数据库)
GVSDSDK_MANAGE_TABLES = True
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Shanghai'
USE_I18N = True
USE_TZ = False
STATIC_URL = 'static/'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
REST_FRAMEWORK = {
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
],
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
'UNAUTHENTICATED_USER': None,
}
MSYNC_MASTER_URL = os.environ.get('MSYNC_MASTER_URL', 'https://www.gvsds.com')
MSYNC_SERVICE_NAME = os.environ.get('MSYNC_SERVICE_NAME', 'atest-cserver')
MSYNC_SERVICE_DOMAIN = os.environ.get('MSYNC_SERVICE_DOMAIN', 'http://127.0.0.1:25080')
MSYNC_SERVICE_SECRET = os.environ.get('MSYNC_SERVICE_SECRET')
if not MSYNC_SERVICE_SECRET:
raise ImproperlyConfigured('MSYNC_SERVICE_SECRET 环境变量未设置,禁止使用默认值启动')
MSYNC_SERVICE_SCOPE = [
'ogm:read', 'ogm:write',
'registry:read',
'tx:participate',
]
MSYNC_HEARTBEAT_INTERVAL = int(os.environ.get('MSYNC_HEARTBEAT_INTERVAL', '30'))
HTTP_PORT = int(os.environ.get('HTTP_PORT', '25080'))
HTTPS_PORT = int(os.environ.get('HTTPS_PORT', '25443'))
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '[{levelname}] {asctime} {module} {message}',
'style': '{',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'verbose',
},
},
'loggers': {
'': {
'handlers': ['console'],
'level': 'INFO',
},
},
}
+21
View File
@@ -0,0 +1,21 @@
"""根 URL 配置"""
from django.urls import path, include
from django.shortcuts import redirect
from apps.sub_server.views import HealthView
from apps.sub_server import views
def root_redirect(request):
return redirect('/api/sub/status/')
urlpatterns = [
path('', root_redirect),
path('health/', HealthView.as_view(), name='health'),
path('api/sub/', include('apps.sub_server.urls')),
# SDK 契约路径:主服务器 Saga 编排引擎按 /api/msync/local/* 回调
path('api/msync/local/execute/', views.LocalExecuteView.as_view(), name='msync_local_execute'),
path('api/msync/local/compensate/', views.LocalCompensateView.as_view(), name='msync_local_compensate'),
path('api/msync/local/reset/', views.LocalResetView.as_view(), name='msync_local_reset'),
]
+5
View File
@@ -0,0 +1,5 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ATestCServer.settings')
application = get_wsgi_application()
View File
Whitespace-only changes.
Whitespace-only changes.
+72
View File
@@ -0,0 +1,72 @@
"""模块:子服务器 AppConfig
Django 启动时自动完成 MSYNC Socket.IO 桥接——连接、注册、认证、心跳。"""
import logging
import threading
from django.apps import AppConfig
from django.conf import settings
logger = logging.getLogger(__name__)
# 与主服务器约定的 SDK 最低版本——版本不一致时启动警告
REQUIRED_GVSDSDK_VERSION = '1.1.0'
class SubServerConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.sub_server'
verbose_name = 'SMANAGE 子服务器'
verbose_name_plural = 'SMANAGE 子服务器'
def ready(self):
# 1) 校验 gvsdsdk 版本与主服务器一致
self._check_sdk_version()
# 2) 注册实验本地事务处理器(供主服务器 Saga 编排回调)
self._register_local_handlers()
# 3) 启动 MSYNC 桥接
self._connect_msync()
def _register_local_handlers(self):
try:
from .local_handlers import RegisterExperimentHandlers
RegisterExperimentHandlers()
logger.info('实验本地事务处理器已注册: demo_a/demo_b/demo_fail/demo_flaky')
except Exception as e:
logger.error(f'实验本地事务处理器注册失败: {e}')
def _check_sdk_version(self):
try:
import gvsdsdk
info = gvsdsdk.get_version_info()
logger.info(f'[SDK] 加载: v{info["Version"]} @ {info["Location"]}')
ok, msg = gvsdsdk.check_min_version(REQUIRED_GVSDSDK_VERSION)
if ok:
logger.info(f'[SDK] {msg}')
else:
logger.warning(f'[SDK] 版本不符!{msg}。双端 gvsdsdk 版本必须一致!')
except ImportError as e:
logger.error(f'[SDK] gvsdsdk 未安装: {e}')
except Exception as e:
logger.error(f'[SDK] 版本检查失败: {e}')
def _connect_msync(self):
try:
from msync_client.sio_client import MSyncSocketClient
msync_client = MSyncSocketClient(
master_url=settings.MSYNC_MASTER_URL,
service_name=settings.MSYNC_SERVICE_NAME,
service_domain=settings.MSYNC_SERVICE_DOMAIN,
service_secret=settings.MSYNC_SERVICE_SECRET,
scope=settings.MSYNC_SERVICE_SCOPE,
heartbeat_interval=settings.MSYNC_HEARTBEAT_INTERVAL,
metadata={'DBName': settings.DATABASES['default']['NAME']},
)
setattr(settings, 'MSYNC_CLIENT', msync_client)
t = threading.Thread(target=msync_client.Connect, daemon=True)
t.start()
logger.info('MSYNC Socket.IO 桥接线程已启动')
except Exception as e:
logger.error(f'MSYNC 桥接启动失败: {e}')
@@ -0,0 +1,22 @@
"""模块:ATestCServer FormService
使用 gvsdsdk 通用 FormService 引擎暴露子服务器 ORM 表。"""
from gvsdsdk.form_service import FormServiceBase
from .models import BusinessUser, BusinessOrder, SyncLog
class ATestCFormService(FormServiceBase):
TABLE_MAP = {
'BusinessUser': BusinessUser,
'BusinessOrder': BusinessOrder,
'SyncLog': SyncLog,
}
ENTITY_PICKER_MAP = {
'BusinessUser': {
'api': '/api/sub/form/action',
'labelField': 'Username',
'valueField': 'UserID',
'placeholder': '输入用户名搜索...',
},
}
@@ -0,0 +1,53 @@
"""实验用本地事务处理器
随子服务器进程(runserver)启动即注册,供主服务器 Saga 编排回调。
msync_experiment 管理命令与 HTTP 服务共用同一进程内处理器。
"""
import uuid
from gvsdsdk.msync.local_tx import local_tx
# demo_flaky 重试实验状态(进程内计数器,可通过 local/reset 端点清零)
_flaky_state = {'attempts': 0}
def _ActionA(payload):
return {'demo_a_uuid': str(uuid.uuid4()), 'received': payload}
def _ActionB(payload):
# 返回收到的完整 payload,供主服务器验证步骤间数据流
return {'received_payload': payload}
def _CompensateA(payload, action_result):
return {'compensated': True, 'action_result': action_result}
def _ActionFail(payload):
raise RuntimeError('确定性失败(实验)')
def _ActionFlaky(payload):
_flaky_state['attempts'] += 1
if _flaky_state['attempts'] == 1:
raise RuntimeError('首次失败(实验:验证自动重试)')
return {'retried': True, 'attempts': _flaky_state['attempts']}
def RegisterExperimentHandlers():
"""注册全部实验处理器(幂等:已存在则跳过)"""
for name, action, compensate in (
('demo_a', _ActionA, _CompensateA),
('demo_b', _ActionB, None),
('demo_fail', _ActionFail, None),
('demo_flaky', _ActionFlaky, None),
):
if not local_tx.HasHandler(name):
local_tx.register(name, action=action, compensate=compensate)
def ResetExperimentState():
"""清零重试实验状态(供 local/reset 端点调用)"""
_flaky_state['attempts'] = 0
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,202 @@
"""msync_experiment — MSYNC 主从链路自动化实验与验证
用法:
python manage.py msync_experiment
前置条件:
- 主服务器在线(SIO + HTTP),且本服务已通过 MSYNC 桥接注册成功
- 本服务 local 端点可被主服务器访问(MSYNC_SERVICE_DOMAIN)
自动执行并输出 PASS/FAIL 汇报表:
1. 环境状态: SDK 版本 / SIO 连接 / 认证 / 注册
2. 正向 Saga: 多步骤成功 → Committed,且验证步骤间数据流
3. 失败补偿: 步骤失败 → 逆序补偿 → Compensated
4. 自动重试: 步骤首次失败 → 主服务器按 CanRetry 自动重试成功
5. 幂等去重: local_tx 相同 Idempotency-Key 命中缓存
"""
import logging
import time
from django.conf import settings
from django.core.management.base import BaseCommand
logger = logging.getLogger(__name__)
def _get_msync():
return getattr(settings, 'MSYNC_CLIENT', None)
class Command(BaseCommand):
help = 'MSYNC 主从链路自动化实验与验证'
def add_arguments(self, parser):
parser.add_argument(
'--wait-seconds', type=int, default=10,
help='等待桥接就绪的秒数(默认 10)',
)
def handle(self, *args, **options):
self.results = []
wait_seconds = options['wait_seconds']
self.out('=' * 60)
self.out('MSYNC 主从链路自动化实验')
self.out('=' * 60)
msync = _get_msync()
if msync is None:
self.fail('MSYNC 未初始化', 'settings.MSYNC_CLIENT 不存在,桥接线程未启动')
return
# 1. 等待桥接就绪(连接/注册/认证)
self.record('等待桥接就绪', self._wait_ready(msync, wait_seconds))
self._report_status(msync)
# 2. 注册本地实验步骤处理器
self.record('注册本地步骤处理器', self._register_handlers(), 'demo_a/demo_b/demo_fail')
# 3. 正向 Saga + 步骤数据流
self.record('正向 Saga(数据流)', self._experiment_forward(msync))
# 4. 失败补偿
self.record('失败补偿', self._experiment_compensate(msync))
# 5. 自动重试
self.record('自动重试', self._experiment_retry(msync))
# 6. 幂等去重
self.record('幂等去重', self._experiment_idempotency())
self._summary()
# ─── 工具 ──────────────────────────────────────────────
def out(self, msg):
self.stdout.write(msg)
def record(self, name, ok, detail=''):
self.results.append((name, bool(ok), detail or ''))
mark = 'PASS' if ok else 'FAIL'
self.stdout.write(f' [{mark}] {name}' + (f' ({detail})' if detail else ''))
def fail(self, name, detail=''):
self.record(name, False, detail)
def _wait_ready(self, msync, seconds):
for _ in range(seconds * 2):
if msync.IsConnected and msync.IsAuthenticated and msync.IsRegistered:
return True
time.sleep(0.5)
return msync.IsConnected and msync.IsAuthenticated and msync.IsRegistered
def _report_status(self, msync):
import gvsdsdk
info = gvsdsdk.get_version_info()
self.out(f'\n SDK 版本 : {info["Version"]} @ {info["Location"]}')
self.out(f' SIO 连接 : {msync.IsConnected}')
self.out(f' 密钥认证 : {msync.IsAuthenticated}')
self.out(f' 服务注册 : {msync.IsRegistered}')
self.out(f' 服务名称 : {msync.ServiceName}')
self.out(f' 主服务器 : {msync.MasterURL}')
# ─── 实验步骤 ──────────────────────────────────────────
def _register_handlers(self):
from apps.sub_server.local_handlers import RegisterExperimentHandlers
from gvsdsdk.msync.local_tx import local_tx
RegisterExperimentHandlers()
return all(local_tx.HasHandler(n) for n in ('demo_a', 'demo_b'))
def _create_and_execute(self, msync, name, steps, timeout_seconds=120):
created = msync.TxCreate(name, steps, timeout_seconds=timeout_seconds)
if not created:
return None, 'TxCreate 失败'
tx_code = created.get('TransactionCode')
detail = msync.TxExecute(tx_code, access_token=msync.AccessToken)
if detail is None:
return tx_code, 'TxExecute 失败'
return tx_code, detail
def _experiment_forward(self, msync):
tx_code, detail = self._create_and_execute(msync, '实验-正向', [
{'name': 'demo_a', 'ParticipantService': msync.ServiceName, 'ActionPayload': {'in': 'a'}},
{'name': 'demo_b', 'ParticipantService': msync.ServiceName, 'ActionPayload': {'in': 'b'}},
])
if not detail:
return False
self.out(f'\n 正向事务 {tx_code}: Status={detail.get("Status")} / Code={detail.get("StatusCode")}')
committed = detail.get('StatusCode') == 2
if not committed:
self.out(f' 事务未提交: {detail}')
return False
steps = {s['Index']: s for s in detail.get('Steps', [])}
step_a, step_b = steps.get(0), steps.get(1)
if not step_a or not step_b:
return False
result_b = (step_b.get('Result') or {}).get('received_payload') or {}
flow_ok = bool(result_b.get('demo_a_uuid')) # demo_b 收到 demo_a 的输出 → 数据流生效
self.out(f' 数据流验证: demo_b 收到 demo_a_uuid = {result_b.get("demo_a_uuid")}')
return committed and flow_ok
def _experiment_compensate(self, msync):
tx_code, detail = self._create_and_execute(msync, '实验-补偿', [
{'name': 'demo_a', 'ParticipantService': msync.ServiceName},
{'name': 'demo_fail', 'ParticipantService': msync.ServiceName, 'MaxRetries': 0},
])
if not detail:
return False
self.out(f'\n 补偿事务 {tx_code}: Status={detail.get("Status")} / Code={detail.get("StatusCode")}')
steps = {s['Index']: s for s in detail.get('Steps', [])}
return detail.get('StatusCode') == 4 and steps.get(0, {}).get('Status') in ('已补偿', 'Compensated')
def _experiment_retry(self, msync):
self._reset_flaky_state()
tx_code, detail = self._create_and_execute(msync, '实验-重试', [
{'name': 'demo_flaky', 'ParticipantService': msync.ServiceName, 'MaxRetries': 2},
])
if not detail:
return False
self.out(f'\n 重试事务 {tx_code}: Status={detail.get("Status")} / Code={detail.get("StatusCode")}')
steps = {s['Index']: s for s in detail.get('Steps', [])}
step = steps.get(0, {})
ok = detail.get('StatusCode') == 2 and step.get('RetryCount', 0) >= 1
self.out(f' 自动重试次数: {step.get("RetryCount")}(要求 >=1)')
return ok
def _reset_flaky_state(self):
"""重置 runserver 进程内的 demo_flaky 计数器(通过 local/reset 端点)"""
import requests
try:
reset_url = f'{settings.MSYNC_SERVICE_DOMAIN}/api/msync/local/reset/'
requests.post(reset_url, timeout=5)
except Exception:
pass
def _experiment_idempotency(self):
from gvsdsdk.msync.local_tx import local_tx
r1 = local_tx.ExecuteStep('no_such_demo', {}, 'EXP:1:0')
r2 = local_tx.ExecuteStep('no_such_demo', {}, 'EXP:1:0')
r3 = local_tx.ExecuteStep('no_such_demo', {}, 'EXP:1:1')
return r1 is r2 and r1 is not r3
# ─── 汇报 ──────────────────────────────────────────────
def _summary(self):
self.out('\n' + '=' * 60)
self.out('实验汇报')
self.out('=' * 60)
passed = sum(1 for _, ok, _ in self.results if ok)
for name, ok, detail in self.results:
mark = '✓' if ok else '✗'
self.out(f' {mark} {name}' + (f' ({detail})' if detail else ''))
self.out(f'\n结果: {passed}/{len(self.results)} 通过')
if passed != len(self.results):
self.stdout.write(self.style.ERROR('存在未通过的实验环节,请查看上方明细'))
raise SystemExit(1)
@@ -0,0 +1,129 @@
"""report_manifest — MSYNC 远端 App 清单上报演示
用法:
python manage.py report_manifest [--app-code custom-demo] [--app-name 定制项目演示]
前置条件:
- 主服务器在线(SIO + HTTP),本服务已通过 MSYNC 注册认证
- 上报后主服务器应用中心可见该 App(Source=msync),
租户管理员可安装到指定公司,配合 RAM 授权角色粒度可见
动作:
1. 构建定制项目 App 清单(页面由本服务器托管,iframe 加载)
2. 通过 MSYNC ReportAppManifests 上报主服务器
3. 输出 Registered/Failed 汇报
"""
import logging
from django.conf import settings
from django.core.management.base import BaseCommand
logger = logging.getLogger(__name__)
def _get_msync():
return getattr(settings, 'MSYNC_CLIENT', None)
def _DefaultManifest(app_code, app_name, service_domain):
"""构建演示清单:App + 两个菜单(首页 + 数据页),挂载在 myself 下"""
base = service_domain.rstrip('/')
return [
{
'AppCode': app_code,
'AppName': app_name,
'AppIcon': 'Connection',
'AppDesc': f'{app_name}——由子服务器 {settings.MSYNC_SERVICE_NAME} 通过 MSYNC 上报的定制页面',
'AppType': 'enterprise',
'AuthRequired': 0,
'AppSort': 50,
'FrontendRoute': f'/remote/{app_code}',
'FrontendType': 'legacy',
'LegacySrc': f'{base}/status/',
'Menus': [
{
'MenuName': f'{app_code}',
'MenuTitle': app_name,
'MenuIcon': 'Connection',
'MenuSort': 1,
},
{
'MenuName': f'{app_code}-home',
'MenuTitle': f'{app_name}·首页',
'MenuIcon': 'Home',
'MenuLink': f'{base}/status/',
'ParentMenuName': f'{app_code}',
'MenuSort': 1,
},
],
'ConfigKeys': [],
}
]
class Command(BaseCommand):
help = 'MSYNC 远端 App 清单上报演示'
def add_arguments(self, parser):
parser.add_argument(
'--app-code', default='custom-demo',
help='演示 AppCode(默认 custom-demo)',
)
parser.add_argument(
'--app-name', default='定制项目演示',
help='演示 AppName(默认 定制项目演示)',
)
parser.add_argument(
'--wait-seconds', type=int, default=10,
help='等待桥接就绪的秒数(默认 10)',
)
def handle(self, *args, **options):
app_code = options['app_code']
app_name = options['app_name']
wait_seconds = options['wait_seconds']
self.stdout.write('=' * 60)
self.stdout.write('MSYNC 远端 App 清单上报演示')
self.stdout.write('=' * 60)
msync = _get_msync()
if msync is None:
self.stdout.write(self.style.ERROR('MSYNC 未初始化:settings.MSYNC_CLIENT 不存在'))
return
# 1. 等待桥接就绪
ready = self._wait_ready(msync, wait_seconds)
if not ready:
self.stdout.write(self.style.ERROR('桥接未就绪(连接/注册/认证失败),中止上报'))
return
self.stdout.write(self.style.SUCCESS(
f'桥接就绪: {msync.ServiceName} @ {msync.ServiceDomain}'
))
# 2. 构建清单并上报
manifests = _DefaultManifest(app_code, app_name, msync.ServiceDomain)
self.stdout.write(f'上报清单: {app_code}({app_name}),菜单 {len(manifests[0]["Menus"])} 个')
ok, result = msync.ReportAppManifests(manifests)
if not ok:
self.stdout.write(self.style.ERROR(f'上报失败: {result}'))
return
self.stdout.write(self.style.SUCCESS(f'上报结果: Registered={result.get("Registered")} Failed={result.get("Failed")}'))
# 3. 提示后续步骤
self.stdout.write('=' * 60)
self.stdout.write('后续步骤(主服务器侧):')
self.stdout.write(' 1. 应用中心可见该 App(来源标记 msync)')
self.stdout.write(' 2. 租户管理员安装到指定公司')
self.stdout.write(' 3. RAMManager.GrantAccess 授权角色可见(可选,不授权则全员可见)')
self.stdout.write(' 4. 员工登录后菜单注入,点击 iframe 加载本服务器页面')
self.stdout.write('=' * 60)
def _wait_ready(self, msync, wait_seconds):
for _ in range(wait_seconds):
if msync.IsConnected and msync.IsAuthenticated and msync.IsRegistered:
return True
import time
time.sleep(1)
return msync.IsConnected and msync.IsAuthenticated and msync.IsRegistered
@@ -0,0 +1,65 @@
# Generated by Django 6.0.5 on 2026-06-11 01:44
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='BusinessUser',
fields=[
('UserID', models.AutoField(primary_key=True, serialize=False)),
('Username', models.CharField(max_length=64, unique=True)),
('Nickname', models.CharField(default='', max_length=128)),
('OGMMappingID', models.CharField(db_index=True, default='', max_length=128)),
('JoinTime', models.DateTimeField(auto_now_add=True)),
('UserStatus', models.SmallIntegerField(default=1)),
],
options={
'verbose_name': '业务用户',
'verbose_name_plural': '业务用户',
'db_table': 'sub_business_user',
},
),
migrations.CreateModel(
name='SyncLog',
fields=[
('LogID', models.AutoField(primary_key=True, serialize=False)),
('EventType', models.CharField(max_length=64)),
('EventDetail', models.JSONField(default=dict)),
('EventStatus', models.SmallIntegerField(default=1)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': '同步日志',
'verbose_name_plural': '同步日志',
'db_table': 'sub_sync_log',
'ordering': ['-CreateTime'],
},
),
migrations.CreateModel(
name='BusinessOrder',
fields=[
('OrderID', models.AutoField(primary_key=True, serialize=False)),
('OrderCode', models.CharField(db_index=True, max_length=64, unique=True)),
('Amount', models.DecimalField(decimal_places=4, max_digits=18)),
('OrderStatus', models.SmallIntegerField(default=0)),
('OGMMappingID', models.CharField(db_index=True, default='', max_length=128)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
('User', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='sub_server.businessuser')),
],
options={
'verbose_name': '业务订单',
'verbose_name_plural': '业务订单',
'db_table': 'sub_business_order',
},
),
]
Whitespace-only changes.
+66
View File
@@ -0,0 +1,66 @@
"""模块:子服务器数据模型
模拟一个真实子服务器的业务数据结构。"""
from django.db import models
class BusinessUser(models.Model):
"""业务用户——模拟子服务器的本地用户数据"""
UserID = models.AutoField(primary_key=True, verbose_name='用户ID')
Username = models.CharField(max_length=64, unique=True, verbose_name='用户名')
Nickname = models.CharField(max_length=128, default='', verbose_name='昵称')
OGMMappingID = models.CharField(max_length=128, default='', db_index=True, verbose_name='OGM映射ID')
JoinTime = models.DateTimeField(auto_now_add=True, verbose_name='加入时间')
UserStatus = models.SmallIntegerField(default=1, verbose_name='用户状态')
class Meta:
db_table = 'sub_business_user'
verbose_name = '业务用户'
verbose_name_plural = '业务用户'
def __str__(self):
return self.Username
class BusinessOrder(models.Model):
"""业务订单——模拟子服务器的本地订单数据"""
OrderID = models.AutoField(primary_key=True, verbose_name='订单ID')
OrderCode = models.CharField(max_length=64, unique=True, db_index=True, verbose_name='订单编号')
User = models.ForeignKey(
BusinessUser, on_delete=models.CASCADE, related_name='orders',
verbose_name='用户',
)
Amount = models.DecimalField(max_digits=18, decimal_places=4, verbose_name='金额')
OrderStatus = models.SmallIntegerField(default=0, verbose_name='订单状态')
OGMMappingID = models.CharField(max_length=128, default='', db_index=True, verbose_name='OGM映射ID')
CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间')
class Meta:
db_table = 'sub_business_order'
verbose_name = '业务订单'
verbose_name_plural = '业务订单'
def __str__(self):
return self.OrderCode
class SyncLog(models.Model):
"""同步日志——记录与主服务器 MSYNC 通信的事件"""
LogID = models.AutoField(primary_key=True, verbose_name='日志ID')
EventType = models.CharField(max_length=64, verbose_name='事件类型')
EventDetail = models.JSONField(default=dict, verbose_name='事件详情')
EventStatus = models.SmallIntegerField(default=1, verbose_name='事件状态')
CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
class Meta:
db_table = 'sub_sync_log'
verbose_name = '同步日志'
verbose_name_plural = '同步日志'
ordering = ['-CreateTime']
def __str__(self):
return f'{self.EventType}@{self.CreateTime}'
@@ -0,0 +1,34 @@
from rest_framework import serializers
from .models import BusinessUser, BusinessOrder, SyncLog
class BusinessUserSerializer(serializers.ModelSerializer):
class Meta:
model = BusinessUser
fields = ['UserID', 'Username', 'Nickname', 'OGMMappingID', 'JoinTime', 'UserStatus']
read_only_fields = ['UserID', 'JoinTime', 'OGMMappingID']
class BusinessOrderSerializer(serializers.ModelSerializer):
class Meta:
model = BusinessOrder
fields = ['OrderID', 'OrderCode', 'Amount', 'OrderStatus', 'OGMMappingID', 'CreateTime', 'UpdateTime', 'User']
read_only_fields = ['OrderID', 'CreateTime', 'UpdateTime', 'OGMMappingID']
class OrderCreateSerializer(serializers.Serializer):
username = serializers.CharField()
amount = serializers.DecimalField(max_digits=18, decimal_places=4)
class OGMRegisterSerializer(serializers.Serializer):
object_type = serializers.CharField()
local_id = serializers.CharField()
metadata = serializers.DictField(required=False, default=dict)
class SyncLogSerializer(serializers.ModelSerializer):
class Meta:
model = SyncLog
fields = ['LogID', 'EventType', 'EventDetail', 'EventStatus', 'CreateTime']
read_only_fields = ['LogID', 'CreateTime']
@@ -0,0 +1,81 @@
"""子服务器服务认证
主服务器调用子服务器 local 端点时携带 `Authorization: Service <token>`。
子服务器无法本地验证令牌(无主库 ServiceToken 表 / MASTER_SECRET),
因此通过主服务器 `/api/msync/auth/introspect/` 远程自省校验。
"""
import json
import logging
import urllib.request
import urllib.error
from django.conf import settings
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
logger = logging.getLogger(__name__)
SERVICE_AUTH_HEADER = 'Authorization'
SERVICE_AUTH_PREFIX = 'Service'
INTROSPECT_PATH = '/api/msync/auth/introspect/'
class ServicePrincipal:
"""服务主体——标识调用方服务"""
def __init__(self, service_name):
self.service_name = service_name
self.service_scope = []
@property
def is_authenticated(self):
return True
def __str__(self):
return f'Service:{self.service_name}'
class SubServerServiceAuthentication(BaseAuthentication):
"""DRF 认证类——校验主服务器 Service 令牌(远程自省)"""
def authenticate(self, request):
auth_header = request.headers.get(SERVICE_AUTH_HEADER, '')
if not auth_header.startswith(f'{SERVICE_AUTH_PREFIX} '):
return None
token = auth_header[len(SERVICE_AUTH_PREFIX) + 1:].strip()
if not token:
return None
payload = self._Introspect(token)
if not payload:
raise AuthenticationFailed('服务令牌验证失败')
return (ServicePrincipal(payload.get('sub', 'unknown')), token)
def authenticate_header(self, request):
return 'Service'
@staticmethod
def _Introspect(access_token):
"""调用主服务器令牌自省端点"""
master_url = str(getattr(settings, 'MSYNC_MASTER_URL', '')).rstrip('/')
if not master_url:
return None
body = json.dumps({'AccessToken': access_token}).encode('utf-8')
req = urllib.request.Request(
f'{master_url}{INTROSPECT_PATH}',
data=body,
headers={'Content-Type': 'application/json'},
method='POST',
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode('utf-8'))
except (urllib.error.URLError, urllib.error.HTTPError, ValueError, OSError) as e:
logger.error(f'令牌自省失败: {e}')
return None
if not data.get('Valid'):
return None
return data.get('Payload') or {}
@@ -0,0 +1,217 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ATestCServer — MSYNC 连接状态</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Segoe UI',system-ui,-apple-system,sans-serif;background:#0f1923;color:#c0d0e0;min-height:100vh}
.header{background:#1a2a3a;border-bottom:2px solid #2a4a6a;padding:20px 30px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px}
.header h1{font-size:22px;color:#e0f0ff;font-weight:600}
.header .badge{padding:6px 14px;border-radius:20px;font-size:13px;font-weight:600;letter-spacing:.5px}
.badge-online{background:#0a3a0a;color:#5ae05a;border:1px solid #1a6a1a}
.badge-offline{background:#3a0a0a;color:#e05a5a;border:1px solid #6a1a1a}
.main{max-width:980px;margin:30px auto;padding:0 20px;display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:20px}
.card{background:#152232;border:1px solid #1e3a50;border-radius:12px;padding:24px;transition:border-color .2s}
.card:hover{border-color:#2a5a80}
.card h3{font-size:14px;color:#6888a8;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:14px}
.card .value{font-size:28px;font-weight:700;color:#e8f8ff;margin-bottom:6px}
.card .sub{font-size:12px;color:#5a7a9a}
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.kv{display:flex;flex-direction:column;gap:3px}
.kv .key{font-size:11px;color:#5a7a9a;text-transform:uppercase;letter-spacing:.8px}
.kv .val{font-size:14px;color:#c8e0f8;word-break:break-all}
.status-row{display:flex;align-items:center;gap:8px;margin:6px 0}
.dot{width:10px;height:10px;border-radius:50%;display:inline-block}
.dot-green{background:#4ade80;box-shadow:0 0 8px #4ade80}
.dot-red{background:#ef4444;box-shadow:0 0 8px #ef4444}
.dot-yellow{background:#f59e0b;box-shadow:0 0 8px #f59e0b}
.dot-gray{background:#555;box-shadow:none}
.divider{border:0;border-top:1px solid #1e3a50;margin:16px 0}
.refresh{font-size:12px;color:#5a7a9a;margin-top:8px;text-align:right}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
.pulse{animation:pulse 2s infinite}
.footer{text-align:center;padding:20px;font-size:12px;color:#3a5a7a}
pre{background:#0a1620;border-radius:8px;padding:12px;overflow-x:auto;font-size:12px;color:#80a0c0;max-height:260px;overflow-y:auto}
.services-list{list-style:none}
.services-list li{padding:8px 10px;border-bottom:1px solid #1a2a3a;font-size:13px;display:flex;justify-content:space-between;color:#90b0d0}
.services-list li:last-child{border-bottom:none}
.section-title{font-size:15px;color:#a0c0e0;font-weight:600;margin-bottom:10px}
</style>
</head>
<body>
<div class="header">
<div>
<h1>ATestCServer</h1>
<span style="font-size:12px;color:#5a7a9a">SMANAGE 子服务器 · HTTP :25080 · HTTPS :25443</span>
</div>
<div style="display:flex;align-items:center;gap:12px">
<span id="clock" style="font-size:14px;color:#6888a8"></span>
<span id="topBadge" class="badge badge-offline">断开</span>
</div>
</div>
<div class="main">
<!-- 连接状态卡片 -->
<div class="card">
<h3>连接状态</h3>
<div class="status-row"><span id="dotConn" class="dot dot-gray"></span> <span id="txtConn">加载中...</span></div>
<div class="status-row"><span id="dotAuth" class="dot dot-gray"></span> <span id="txtAuth">加载中...</span></div>
<div class="status-row"><span id="dotReg" class="dot dot-gray"></span> <span id="txtReg">加载中...</span></div>
<div class="status-row"><span id="dotHear" class="dot dot-gray"></span> <span id="txtHear">加载中...</span></div>
<div class="refresh pulse" id="refresher">自动刷新中...</div>
</div>
<!-- 服务信息卡片 -->
<div class="card">
<h3>服务标识</h3>
<div class="grid-2">
<div class="kv"><span class="key">服务名</span><span class="val" id="svcName">—</span></div>
<div class="kv"><span class="key">域名</span><span class="val" id="svcDomain">—</span></div>
<div class="kv"><span class="key">主服务器</span><span class="val" id="svcMaster">—</span></div>
<div class="kv"><span class="key">Token</span><span class="val" id="svcToken">—</span></div>
</div>
</div>
<!-- 对等服务卡片 -->
<div class="card" style="grid-column:span 2">
<h3>对等服务</h3>
<ul class="services-list" id="peerList"><li style="color:#5a7a9a">加载中...</li></ul>
</div>
<!-- 最近事件卡片 -->
<div class="card" style="grid-column:span 2">
<h3>最近同步事件</h3>
<ul class="services-list" id="eventLog"><li style="color:#5a7a9a">暂无事件</li></ul>
</div>
<!-- 原始数据卡片 -->
<div class="card" style="grid-column:span 2">
<h3>原始响应</h3>
<pre id="rawData">{}</pre>
</div>
</div>
<div class="footer">
MSYNC Bridge · Heartbeat/30s · ATestCServer &copy; SMANAGE
</div>
<script>
const HEALTH_URL = '/health/';
const STATUS_URL = '/api/sub/msync/status/';
function fmtTime(){const n=new Date();return n.toLocaleTimeString('zh-CN',{hour12:false})}
function updClock(){document.getElementById('clock').textContent=fmtTime()}
async function fetchStatus(){
try{
const[h,s]=await Promise.all([
fetch(HEALTH_URL).then(r=>r.json()),
fetch(STATUS_URL).then(r=>r.json())
]);
render(h?.data,h?.code===200,s?.data,s?.code===200);
}catch(e){
showError('请求失败: '+e.message);
}
}
function dot(cls){return '<span class="dot '+cls+'"></span>'}
function showError(msg){
document.getElementById('topBadge').className='badge badge-offline';
document.getElementById('topBadge').textContent='错误';
document.getElementById('dotConn').className='dot dot-red';
document.getElementById('txtConn').innerHTML=msg;
document.getElementById('refresher').textContent='刷新失败,10s后重试';
}
function render(hdata,hok,sdata,sok){
const connected = hdata?.MSync?.Connected || false;
const authenticated = hdata?.MSync?.Authenticated || false;
const registered = hdata?.MSync?.Registered || false;
const running = sdata?.running || connected;
const tokenRaw = hdata?.MSync?.AccessToken || sdata?.AccessToken || null;
// 顶部徽章
const badge = document.getElementById('topBadge');
if(connected && authenticated){
badge.className='badge badge-online'; badge.textContent='已连接';
}else if(connected){
badge.className='badge badge-online'; badge.textContent='已注册';
}else{
badge.className='badge badge-offline'; badge.textContent='断开';
}
// 连接状态
document.getElementById('dotConn').className='dot '+(connected?'dot-green pulse':'dot-red');
document.getElementById('txtConn').textContent='MSYNC 运行: '+(running?'是':'否');
// 认证状态——只有连接成功后才看 token,否则一律视为未认证
const authEffective = connected && authenticated;
const tokenEffective = connected && tokenRaw;
document.getElementById('dotAuth').className='dot '+(authEffective?'dot-green':tokenEffective?'dot-yellow':'dot-red');
document.getElementById('txtAuth').textContent='服务认证: '+(authEffective?'已认证':tokenEffective?'令牌存在(可能过期)':connected?'认证中':'未连接');
document.getElementById('dotReg').className='dot '+(registered?'dot-green':'dot-red');
document.getElementById('txtReg').textContent='服务注册: '+(registered?'已注册':'未注册');
document.getElementById('dotHear').className='dot '+(connected&&registered?'dot-green pulse':'dot-gray');
document.getElementById('txtHear').textContent='心跳线程: '+(connected&&registered?'运行中(间隔30s)':'未启动');
// 服务信息
document.getElementById('svcName').textContent=hdata?.MSync?.ServiceName||sdata?.ServiceName||'—';
document.getElementById('svcDomain').textContent=hdata?.MSync?.ServiceDomain||sdata?.ServiceDomain||'—';
document.getElementById('svcMaster').textContent=hdata?.MSync?.MasterURL||sdata?.MasterURL||'—';
// Token 截断展示(防完整泄露)
const tokenEl = document.getElementById('svcToken');
if(tokenRaw && tokenRaw.length>40){
tokenEl.textContent = tokenRaw.slice(0,30)+'…'+tokenRaw.slice(-15);
tokenEl.title = tokenRaw;
tokenEl.style.cursor = 'help';
}else{
tokenEl.textContent = tokenRaw||'—';
}
// 对等服务(PascalCase 字段:Name/Domain)
const peers=sdata?.PeerServices||[];
const pl=document.getElementById('peerList');
if(peers.length===0){
pl.innerHTML='<li style="color:#5a7a9a">无对等服务(主服务器可能未上线,或尚无其他子服务器)</li>';
}else{
pl.innerHTML=peers.map(p=>
'<li><span>'+(p.Name||p.name||'?')+'</span><span style="color:#6888a8;font-size:11px">'+(p.Domain||p.domain||'')+'</span></li>'
).join('');
}
// 事件日志
fetch('/api/sub/sync/logs/').then(r=>r.json()).then(d=>{
const logs=d?.data||[];
const el=document.getElementById('eventLog');
if(logs.length===0){
el.innerHTML='<li style="color:#5a7a9a">暂无同步事件(需要与主服务器通信后产生)</li>';
}else{
el.innerHTML=logs.slice(0,10).map(l=>
'<li><span>'+l.EventType+'</span><span style="color:#5a7a9a;font-size:11px">'+l.CreateTime+'</span></li>'
).join('');
}
}).catch(()=>{});
// 原始数据
document.getElementById('rawData').textContent=JSON.stringify({
health: hdata?.msync||{},
status: {running:running,authenticated:authenticated,registered:registered,peers:peers.length}
},null,2);
document.getElementById('refresher').textContent='最后刷新: '+fmtTime();
}
updClock();
setInterval(updClock,1000);
fetchStatus();
setInterval(fetchStatus,10000);
</script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
from django.urls import path
from . import views
app_name = 'sub_server'
urlpatterns = [
path('health/', views.HealthView.as_view(), name='health'),
path('status/', views.StatusPageView, name='status_page'),
path('msync/status/', views.MSyncStatusView.as_view(), name='msync_status'),
path('users/', views.UserListView.as_view(), name='user_list'),
path('users/<int:user_id>/', views.UserDetailView.as_view(), name='user_detail'),
path('orders/', views.OrderListView.as_view(), name='order_list'),
path('orders/<int:order_id>/', views.OrderDetailView.as_view(), name='order_detail'),
path('ogm/register/', views.OGMRegisterView.as_view(), name='ogm_register'),
path('ogm/resolve/<str:global_id>/', views.OGMResolveView.as_view(), name='ogm_resolve'),
path('remote/call/', views.RemoteCallDemoView.as_view(), name='remote_call'),
path('sync/logs/', views.SyncLogView.as_view(), name='sync_logs'),
path('local/execute/', views.LocalExecuteView.as_view(), name='local_execute'),
path('local/compensate/', views.LocalCompensateView.as_view(), name='local_compensate'),
path('local/handlers/', views.LocalHandlersView.as_view(), name='local_handlers'),
]
+420
View File
@@ -0,0 +1,420 @@
"""模块:子服务器 REST API 视图
提供业务 CRUD 端点 + MSYNC 状态查询 + 服务间调用演示 + FormService ORM 管理。"""
import logging
from django.conf import settings
from django.shortcuts import render
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import AllowAny, IsAuthenticated
from .service_auth import SubServerServiceAuthentication
from .models import BusinessUser, BusinessOrder, SyncLog
from .serializers import (
BusinessUserSerializer,
BusinessOrderSerializer,
OrderCreateSerializer,
OGMRegisterSerializer,
SyncLogSerializer,
)
logger = logging.getLogger(__name__)
def _ok(data=None, msg='success'):
return {'code': 200, 'msg': msg, 'data': data}
def _err(msg, code=400):
return {'code': code, 'msg': msg, 'data': None}
def _get_msync():
"""获取 MSYNC 客户端实例"""
return getattr(settings, 'MSYNC_CLIENT', None)
def _log_sync(event_type, detail, status=1):
"""记录同步日志"""
try:
SyncLog.objects.create(
EventType=event_type,
EventDetail=detail,
EventStatus=status,
)
except Exception:
pass
class HealthView(APIView):
authentication_classes = []
permission_classes = [AllowAny]
def get(self, request):
msync = _get_msync()
token = msync.AccessToken if msync else None
msync_status = {
'Connected': msync.IsConnected if msync else False,
'Authenticated': msync.IsAuthenticated if msync else False,
'Registered': msync.IsRegistered if msync else False,
'ServiceName': msync.ServiceName if msync else None,
'ServiceDomain': msync.ServiceDomain if msync else None,
'MasterURL': msync.MasterURL if msync else None,
'AccessToken': 'exists' if token else 'none',
}
# SDK 版本信息
sdk_info = {'Name': 'gvsdsdk'}
try:
import gvsdsdk
sdk_info.update(gvsdsdk.get_version_info())
except ImportError as e:
sdk_info['Error'] = str(e)
# SAAS 数据库连接状态
saas_db_status = {'Configured': 'saas' in settings.DATABASES}
try:
from django.db import connections
conn = connections['saas']
conn.ensure_connection()
saas_db_status['Connected'] = True
# 尝试统计用户数验证连接可用
from gvsdsdk.models import User
saas_db_status['UserCount'] = User.objects.using('saas').count()
except Exception as e:
saas_db_status['Connected'] = False
saas_db_status['Error'] = str(e)
return Response(_ok({
'Server': 'ATestCServer',
'Status': 'running',
'MSync': msync_status,
'SDK': sdk_info,
'SAASDB': saas_db_status,
}))
class MSyncStatusView(APIView):
authentication_classes = []
permission_classes = [AllowAny]
def get(self, request):
msync = _get_msync()
if not msync:
return Response(_err('MSYNC 未初始化', 500), status=500)
services = msync.DiscoverAll()
token = msync.AccessToken
return Response(_ok({
'ServiceName': msync.ServiceName,
'ServiceDomain': msync.ServiceDomain,
'MasterURL': msync.MasterURL,
'Authenticated': msync.IsAuthenticated,
'Registered': msync.IsRegistered,
'Connected': msync.IsConnected,
'AccessToken': 'exists' if token else 'none',
'PeerServices': services,
}))
class UserListView(APIView):
def get(self, request):
users = BusinessUser.objects.filter(UserStatus=1).order_by('-JoinTime')
return Response(_ok(BusinessUserSerializer(users, many=True).data))
def post(self, request):
username = request.data.get('username', '').strip()
nickname = request.data.get('nickname', '')
if not username:
return Response(_err('用户名不能为空', 400), status=400)
user, created = BusinessUser.objects.get_or_create(
Username=username,
defaults={'Nickname': nickname or username},
)
if created:
msync = _get_msync()
if msync:
try:
ogm_result = msync.OGMRegister(
object_type='user',
local_id=str(user.UserID),
service_name=msync.ServiceName,
metadata={'username': username, 'nickname': nickname or username},
)
if ogm_result:
user.OGMMappingID = ogm_result.get('ObjectGlobalID', '')
user.save(update_fields=['OGMMappingID'])
_log_sync('ogm_register', {
'object_type': 'user',
'local_id': str(user.UserID),
'global_id': user.OGMMappingID,
})
except Exception as e:
logger.warning(f'OGM注册失败: {e}')
status_code = 201 if created else 200
return Response(_ok(BusinessUserSerializer(user).data), status=status_code)
class UserDetailView(APIView):
def get(self, request, user_id):
try:
user = BusinessUser.objects.get(UserID=user_id)
except BusinessUser.DoesNotExist:
return Response(_err('用户不存在', 404), status=404)
return Response(_ok(BusinessUserSerializer(user).data))
class OrderListView(APIView):
def get(self, request):
orders = BusinessOrder.objects.all().order_by('-CreateTime')[:50]
return Response(_ok(BusinessOrderSerializer(orders, many=True).data))
def post(self, request):
ser = OrderCreateSerializer(data=request.data)
if not ser.is_valid():
return Response(_err(ser.errors, 400), status=400)
username = ser.validated_data['username']
amount = ser.validated_data['amount']
try:
user = BusinessUser.objects.get(Username=username)
except BusinessUser.DoesNotExist:
return Response(_err('用户不存在', 404), status=404)
import uuid
order = BusinessOrder.objects.create(
OrderCode=f'SUB-{uuid.uuid4().hex[:12].upper()}',
User=user,
Amount=amount,
OrderStatus=0,
)
msync = _get_msync()
if msync:
try:
ogm_result = msync.OGMRegister(
object_type='order',
local_id=str(order.OrderID),
service_name=msync.ServiceName,
metadata={
'order_code': order.OrderCode,
'amount': str(amount),
'user_id': str(user.UserID),
},
)
if ogm_result:
order.OGMMappingID = ogm_result.get('ObjectGlobalID', '')
order.save(update_fields=['OGMMappingID'])
_log_sync('ogm_register', {
'object_type': 'order',
'local_id': str(order.OrderID),
'global_id': order.OGMMappingID,
})
except Exception as e:
logger.warning(f'订单OGM注册失败: {e}')
return Response(_ok(BusinessOrderSerializer(order).data), status=201)
class OrderDetailView(APIView):
def get(self, request, order_id):
try:
order = BusinessOrder.objects.get(OrderID=order_id)
except BusinessOrder.DoesNotExist:
return Response(_err('订单不存在', 404), status=404)
return Response(_ok(BusinessOrderSerializer(order).data))
class OGMRegisterView(APIView):
def post(self, request):
ser = OGMRegisterSerializer(data=request.data)
if not ser.is_valid():
return Response(_err(ser.errors, 400), status=400)
msync = _get_msync()
if not msync:
return Response(_err('MSYNC 未初始化', 500), status=500)
result = msync.OGMRegister(
object_type=ser.validated_data['object_type'],
local_id=ser.validated_data['local_id'],
service_name=msync.ServiceName,
metadata=ser.validated_data.get('metadata', {}),
)
if result:
_log_sync('ogm_register_manual', {
'object_type': ser.validated_data['object_type'],
'local_id': ser.validated_data['local_id'],
'global_id': result.get('ObjectGlobalID', ''),
})
return Response(_ok(result), status=201)
return Response(_err('OGM注册失败', 500), status=500)
class OGMResolveView(APIView):
def get(self, request, global_id):
msync = _get_msync()
if not msync:
return Response(_err('MSYNC 未初始化', 500), status=500)
result = msync.OGMResolve(global_id)
if result:
return Response(_ok(result))
return Response(_err('映射不存在', 404), status=404)
class RemoteCallDemoView(APIView):
def get(self, request):
msync = _get_msync()
if not msync:
return Response(_err('MSYNC 未初始化', 500), status=500)
services = msync.DiscoverAll()
results = []
for svc in services:
svc_name = svc.get('ServiceName', '')
if svc_name == msync.ServiceName:
continue
target_domain = svc.get('ServiceDomain', '')
resp = msync.CallRemote(
service_name=svc_name,
method='GET',
path='/health/',
)
if resp is None:
results.append({
'service': svc_name,
'domain': target_domain,
'error': '远端调用失败',
})
else:
# CallRemote 返回 requests.Response(或 dict 时兼容)
if isinstance(resp, dict):
body = resp
else:
try:
body = resp.json()
except ValueError:
body = {'code': resp.status_code, 'msg': resp.text[:200], 'data': None}
results.append({
'service': svc_name,
'domain': target_domain,
'code': body.get('code'),
'msg': body.get('msg'),
'data': body.get('data'),
})
return Response(_ok(results))
class SyncLogView(APIView):
authentication_classes = []
permission_classes = [AllowAny]
def get(self, request):
logs = SyncLog.objects.all()[:50]
return Response(_ok(SyncLogSerializer(logs, many=True).data))
def StatusPageView(request):
"""连接状态仪表盘页面"""
return render(request, 'status.html', {
'service_name': settings.MSYNC_SERVICE_NAME,
'service_domain': settings.MSYNC_SERVICE_DOMAIN,
'master_url': settings.MSYNC_MASTER_URL,
})
class LocalExecuteView(APIView):
"""本地事务执行端点——由主服务器 Saga 编排引擎调用(需主服务器 Service 令牌)"""
authentication_classes = [SubServerServiceAuthentication]
permission_classes = [IsAuthenticated]
def post(self, request):
from gvsdsdk.msync.local_tx import local_tx
step_name = request.data.get('StepName', '')
payload = request.data.get('payload') or {}
idempotency_key = request.headers.get('Idempotency-Key')
if not step_name:
return Response(_err('StepName 不能为空', 400), status=400)
result = local_tx.ExecuteStep(
name=step_name,
payload=payload,
idempotency_key=idempotency_key,
)
if result['success']:
return Response(_ok(result['result']))
return Response(_err(result['error'], 500), status=500)
class LocalCompensateView(APIView):
"""本地事务补偿端点——由主服务器 Saga 编排引擎调用(需主服务器 Service 令牌)"""
authentication_classes = [SubServerServiceAuthentication]
permission_classes = [IsAuthenticated]
def post(self, request):
from gvsdsdk.msync.local_tx import local_tx
step_name = request.data.get('StepName', '')
payload = request.data.get('payload') or {}
action_result = request.data.get('ActionResult') or {}
idempotency_key = request.headers.get('Idempotency-Key')
if not step_name:
return Response(_err('StepName 不能为空', 400), status=400)
result = local_tx.CompensateStep(
name=step_name,
payload=payload,
action_result=action_result,
idempotency_key=idempotency_key,
)
if result['success']:
return Response(_ok(result['result']))
return Response(_err(result['error'], 500), status=500)
class LocalHandlersView(APIView):
"""列出已注册的本地事务步骤处理器"""
authentication_classes = [SubServerServiceAuthentication]
permission_classes = [IsAuthenticated]
def get(self, request):
from gvsdsdk.msync.local_tx import local_tx
return Response(_ok(local_tx.ListHandlers()))
class LocalResetView(APIView):
"""重置实验处理器状态(demo_flaky 重试计数清零),仅限本地回环使用"""
authentication_classes = []
permission_classes = [AllowAny]
def post(self, request):
from .local_handlers import ResetExperimentState
ResetExperimentState()
return Response(_ok({'reset': True}))
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env python
import os
import sys
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def main():
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
os.environ['DJANGO_SETTINGS_MODULE'] = 'ATestCServer.settings'
os.environ['DB_NAME'] = 'atestc'
# 安全:禁止硬编码默认密钥/口令,缺失时显式报错(settings.py 亦有
# ImproperlyConfigured 兜底)。MSYNC_SERVICE_SECRET 与 DB_PASSWORD
# 由部署环境(环境变量)提供。
required_envs = ['DJANGO_SECRET_KEY', 'MSYNC_SERVICE_SECRET', 'DB_PASSWORD']
missing = [name for name in required_envs if not os.environ.get(name)]
if missing:
raise SystemExit(
'缺少必需的环境变量: ' + ', '.join(missing)
+ '(禁止使用默认值,请通过环境变量注入)'
)
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Make sure it's installed and available."
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
+9
View File
@@ -0,0 +1,9 @@
"""MSYNC 客户端
模块入口,导出:
- MSyncSocketClient: Socket.IO 客户端(实时通道)
"""
from .sio_client import MSyncSocketClient
__all__ = ['MSyncSocketClient']
+540
View File
@@ -0,0 +1,540 @@
"""模块:MSYNC Socket.IO 客户端
替代 HTTP REST,通过 Socket.IO 与 SMANAGE 主服务器实时通信。
所有方法名、事件字段全部使用 PascalCase。
优势:
- 连接复用,无需每次请求建立 TCP/TLS
- 内置心跳保活,替代应用层心跳线程
- 服务端可主动推送事件(服务上下线通知)
- 断线自动重连
"""
import hashlib
import logging
import threading
import time
import socketio
from gvsdsdk.msync.auth import GenerateChallenge, BuildAuthPayload, SignPayload
logger = logging.getLogger(__name__)
class MSyncSocketClient:
"""MSYNC Socket.IO 客户端——子服务器接入 SMANAGE 的实时通道"""
def __init__(self, master_url, service_name, service_domain,
service_secret, scope=None, heartbeat_interval=30,
service_type=0, metadata=None):
self.MasterURL = master_url.rstrip('/')
self.ServiceName = service_name
self.ServiceDomain = service_domain
self.ServiceSecret = service_secret
self.Scope = scope or ['ogm:read', 'ogm:write', 'registry:read']
self.HeartbeatInterval = heartbeat_interval
self.ServiceType = service_type
self.Metadata = metadata or {}
self._sio = socketio.Client(
reconnection=True,
reconnection_attempts=0,
reconnection_delay=2,
reconnection_delay_max=30,
logger=True,
engineio_logger=True,
)
self._token_data = None
self._registered = False
self._running = False
self._heartbeat_thread = None
self._initial_connected = False
self._reconnect_lock = threading.Lock()
self._SetupEvents()
def _SetupEvents(self):
@self._sio.on('connect', namespace='/msync')
def on_connect():
logger.info(f'[SIO] ← connect ns=/msync')
if self._initial_connected:
logger.info(f'[SIO] 重连 detected, 重新注册/认证')
t = threading.Thread(target=self._DoReconnect, daemon=True)
t.start()
@self._sio.on('disconnect', namespace='/msync')
def on_disconnect():
logger.warning(f'[SIO] ← disconnect ns=/msync')
self._registered = False
self._token_data = None
logger.info(f'[SIO] 状态已清理: registered=False, token_data=None')
@self._sio.on('msync:service:online', namespace='/msync')
def on_service_online(data):
logger.info(f'[SIO] ← msync:service:online data={data}')
@self._sio.on('msync:service:offline', namespace='/msync')
def on_service_offline(data):
logger.info(f'[SIO] ← msync:service:offline data={data}')
@self._sio.on('msync:force:deregister', namespace='/msync')
def on_force_deregister(data):
logger.warning(f'[SIO] ← msync:force:deregister data={data}')
self._registered = False
self._token_data = None
# 延迟后自动重新注册
t = threading.Thread(target=self._DoReconnect, daemon=True)
t.start()
@self._sio.on('*')
def catch_all(event, data):
logger.info(f'[SIO] ← unhandled event="{event}" data={data}')
# ---------- 连接管理 ----------
def Connect(self):
self._running = True
url = self.MasterURL
try:
logger.info(f'[SIO] → connect url={url} ns=/msync')
self._sio.connect(url, namespaces=['/msync'], wait=True, wait_timeout=15)
except Exception as e:
logger.warning(f'Socket.IO 连接失败: {e},启动兜底重连线程')
self._StartBootstrap()
return
self._OnReady()
def _StartBootstrap(self):
"""初次连接失败的兜底:循环重连,成功后走完整注册/认证流程
python-socketio 的 reconnection 仅在"已连接后断开"时生效,
初次 connect 失败不会自动重连,需自行兜底。
"""
def bootstrap():
while self._running and not self._sio.connected:
try:
logger.info(f'[SIO] 兜底重连尝试: {self.MasterURL}')
self._sio.connect(
self.MasterURL,
namespaces=['/msync'],
wait=True,
wait_timeout=10,
)
break
except Exception as e:
logger.warning(f'[SIO] 兜底重连失败: {e}')
time.sleep(3)
if self._sio.connected:
self._OnReady()
threading.Thread(target=bootstrap, daemon=True, name='msync-bootstrap').start()
def _OnReady(self):
"""连接建立后的统一就绪流程:注册/认证/心跳/ORM 中转"""
logger.info(f'[SIO] Socket 已连接, 开始注册/认证...')
for attempt in range(1, 4):
reg_ok = self._DoRegister()
auth_ok = self._DoAuth()
if reg_ok and auth_ok:
break
logger.warning(f'[SIO] 首次注册/认证失败 第 {attempt}/3 次 (reg={reg_ok}, auth={auth_ok})')
if attempt < 3:
time.sleep(2)
self._DoHeartbeat()
self._initial_connected = True
self._StartHeartbeat()
# 注册 ORM 中转处理器
try:
from gvsdsdk.msync.orm_handler import ORMRelayHandler
self._orm_handler = ORMRelayHandler()
self._orm_handler.Register(self)
except Exception as e:
logger.warning(f'[ORM] ORM 中转处理器注册失败: {e}')
logger.info(f'MSYNC Socket.IO 客户端已启动: {self.ServiceName}')
def Disconnect(self):
self._running = False
try:
self._DoDeregister()
except Exception:
pass
try:
self._sio.disconnect()
except Exception:
pass
logger.info(f'MSYNC Socket.IO 客户端已断开: {self.ServiceName}')
def _DoReconnect(self, max_retries=3):
"""重连后重新注册/认证,失败时自动重试(内部持有互斥锁)"""
if not self._reconnect_lock.acquire(blocking=False):
logger.warning('[SIO] 重连已在进行中,跳过本次重连')
return
try:
for attempt in range(1, max_retries + 1):
logger.info(f'[SIO] 重连注册/认证 第 {attempt}/{max_retries} 次')
reg_ok = self._DoRegister()
auth_ok = self._DoAuth()
if reg_ok and auth_ok:
logger.info(f'[SIO] 重连注册/认证成功')
return
logger.warning(f'[SIO] 重连注册/认证失败 (reg={reg_ok}, auth={auth_ok}),{2}s 后重试')
if attempt < max_retries:
time.sleep(2)
logger.error(f'[SIO] 重连注册/认证 {max_retries} 次均失败,等待 Socket.IO 下次重连触发')
finally:
self._reconnect_lock.release()
def _StartHeartbeat(self):
if self._heartbeat_thread and self._heartbeat_thread.is_alive():
return
self._heartbeat_thread = threading.Thread(
target=self._HeartbeatLoop,
daemon=True,
name=f'msync-heartbeat-{self.ServiceName}',
)
self._heartbeat_thread.start()
logger.info(f'心跳线程已启动, 间隔={self.HeartbeatInterval}s')
def _HeartbeatLoop(self):
consecutive_failures = 0
while self._running:
time.sleep(self.HeartbeatInterval)
try:
if not self._sio.connected:
continue
# 检查令牌是否即将过期,主动续期
if not self.IsAuthenticated:
logger.info('[SIO] 令牌已过期或即将过期,主动续期')
try:
self.EnsureAuthenticated()
except Exception as e:
logger.warning(f'[SIO] 令牌续期失败: {e},触发重连')
consecutive_failures += 1
if consecutive_failures >= 3:
logger.warning('[SIO] 连续失败次数过多,强制重连')
self._ForceReconnect()
consecutive_failures = 0
continue
# 发送心跳
result = self._Call('msync:heartbeat', {})
if result and result.get('code') not in (200, 0):
code = result.get('code')
msg = result.get('msg', '')
logger.warning(f'[SIO] 心跳异常: code={code} msg={msg}')
# 404 = 服务不存在/已禁用,需要立即重新注册
if code == 404:
logger.warning('[SIO] 服务已被注销,立即重新注册')
try:
reg_ok = self._DoRegister()
auth_ok = self._DoAuth()
if reg_ok and auth_ok:
logger.info('[SIO] 重新注册/认证成功')
consecutive_failures = 0
continue
except Exception as e:
logger.error(f'[SIO] 重新注册失败: {e}')
# 注册也失败,强制重连
self._ForceReconnect()
consecutive_failures = 0
continue
consecutive_failures += 1
if consecutive_failures >= 3:
logger.warning('[SIO] 心跳连续失败次数过多,强制重连')
self._ForceReconnect()
consecutive_failures = 0
elif result:
consecutive_failures = 0
except Exception as e:
logger.warning(f'[SIO] 心跳异常: {e}')
consecutive_failures += 1
if consecutive_failures >= 3:
self._ForceReconnect()
consecutive_failures = 0
def _Call(self, event, data, timeout=15):
if not self._sio.connected:
logger.warning(f'[SIO] ✗ 未连接,无法发送 event={event}')
return None
try:
logger.info(f'[SIO] → event="{event}" ns=/msync data_keys={list(data.keys()) if isinstance(data, dict) else type(data).__name__}')
result = self._sio.call(event, data, namespace='/msync', timeout=timeout)
logger.info(f'[SIO] ← ack event="{event}" result={result}')
return result
except socketio.exceptions.TimeoutError:
logger.error(f'[SIO] ✗ timeout event="{event}" ({timeout}s)')
return None
except Exception as e:
logger.error(f'[SIO] ✗ call failed event="{event}": {type(e).__name__}: {e}')
return None
# ---------- 注册与认证 ----------
def _DoRegister(self):
payload = {
'ServiceName': self.ServiceName,
'ServiceDomain': self.ServiceDomain,
'ServiceSecretHash': hashlib.sha256(self.ServiceSecret.encode()).hexdigest(),
'ServiceSecret': self.ServiceSecret,
'ServiceScope': self.Scope,
'ServiceType': self.ServiceType,
'ServiceMetadata': self.Metadata,
}
result = self._Call('msync:register', payload)
if result and result.get('code') == 200:
self._registered = True
logger.info(f'服务注册成功: {self.ServiceName}')
return True
if result and result.get('code') == 409:
logger.warning(f'服务已存在(409冲突),可能已用不同密钥注册: {self.ServiceName},尝试认证')
return True
logger.warning(f'服务注册失败: result={result}')
return False
def _DoAuth(self):
challenge = GenerateChallenge()
payload = BuildAuthPayload(self.ServiceName, challenge)
signature = SignPayload(payload, self.ServiceSecret)
# 调试:打印签名详情
import json
canonical = json.dumps(payload, sort_keys=True, separators=(',', ':'))
logger.info(f'[AUTH] 客户端签名: service={self.ServiceName}, canonical={canonical}, sig={signature}')
result = self._Call('msync:auth', {
'ServiceName': self.ServiceName,
'Payload': payload,
'Signature': signature,
})
if result and result.get('code') == 200 and result.get('data'):
token_data = result['data']
self._token_data = {
'AccessToken': token_data.get('AccessToken'),
'RefreshToken': token_data.get('RefreshToken'),
'Scope': token_data.get('Scope', []),
'ExpiresAt': token_data.get('ExpiresAt'),
}
logger.info(f'服务认证成功: {self.ServiceName}')
return True
logger.warning(f'服务认证失败: result={result}')
return False
def _DoDeregister(self):
self._Call('msync:deregister', {'ServiceName': self.ServiceName})
self._registered = False
def _DoHeartbeat(self):
result = self._Call('msync:heartbeat', {})
if result and result.get('code') == 200:
logger.info(f'初始心跳成功: {self.ServiceName}')
else:
logger.warning(f'初始心跳失败: {result}')
# ---------- 状态 ----------
@property
def AccessToken(self):
return self._token_data.get('AccessToken') if self._token_data else None
@property
def RefreshToken(self):
return self._token_data.get('RefreshToken') if self._token_data else None
@property
def IsAuthenticated(self):
if not self._token_data:
return False
expires = self._token_data.get('ExpiresAt', 0)
if expires and time.time() > expires - 60:
return False
return True
@property
def IsRegistered(self):
return self._registered
@property
def IsConnected(self):
return self._sio.connected
def EnsureAuthenticated(self):
"""确保认证有效,过期则续期,续期失败则断开重连"""
if not self.IsAuthenticated:
if self._token_data and self._token_data.get('RefreshToken'):
result = self._RefreshToken()
if result:
return result
logger.warning('[SIO] 令牌刷新失败,尝试完整认证')
auth_ok = self._DoAuth()
if auth_ok:
return self._token_data
# 认证彻底失败,断开连接触发 Socket.IO 自动重连
logger.error('[SIO] 完整认证也失败,断开连接触发自动重连')
self._ForceReconnect()
return None
return self._token_data
def _RefreshToken(self):
result = self._Call('msync:auth:refresh', {
'RefreshToken': self._token_data.get('RefreshToken', ''),
})
if result and result.get('code') == 200 and result.get('data'):
token_data = result['data']
self._token_data = {
'AccessToken': token_data.get('AccessToken'),
'RefreshToken': token_data.get('RefreshToken'),
'Scope': token_data.get('Scope', []),
'ExpiresAt': token_data.get('ExpiresAt'),
}
logger.info(f'令牌刷新成功: {self.ServiceName}')
return self._token_data
logger.info('令牌刷新失败,回退到完整认证')
return None
def _ForceReconnect(self):
"""强制断开连接,让 Socket.IO 自动重连"""
try:
self._registered = False
self._token_data = None
self._sio.disconnect()
except Exception as e:
logger.warning(f'[SIO] 强制断开失败: {e}')
def _Headers(self):
token = self.AccessToken
if not token:
self.EnsureAuthenticated()
token = self.AccessToken
return {'Authorization': f'Bearer {token}'} if token else {}
# ---------- 服务发现 ----------
def DiscoverAll(self, active_only=True):
result = self._Call('msync:discover', {'ActiveOnly': active_only})
if result and result.get('code') == 200:
return result.get('data', [])
return []
def Discover(self, service_name):
result = self._Call('msync:discover:one', {'ServiceName': service_name})
if result and result.get('code') == 200:
return result.get('data')
return None
# ---------- OGM ----------
def OGMRegister(self, object_type, local_id, service_name,
global_id=None, metadata=None):
body = {
'ObjectType': object_type,
'ObjectLocalID': str(local_id),
'ServiceName': service_name,
}
if global_id:
body['ObjectGlobalID'] = global_id
if metadata:
body['Metadata'] = metadata
result = self._Call('msync:ogm:register', body)
if result and result.get('code') == 200:
return result.get('data')
return None
def OGMResolve(self, global_id):
result = self._Call('msync:ogm:resolve', {'ObjectGlobalID': global_id})
if result and result.get('code') == 200:
return result.get('data')
return None
def OGMQuery(self, object_type=None, service_name=None, local_id=None):
body = {}
if object_type:
body['ObjectType'] = object_type
if service_name:
body['ServiceName'] = service_name
if local_id:
body['ObjectLocalID'] = local_id
result = self._Call('msync:ogm:query', body)
if result and result.get('code') == 200:
return result.get('data')
return None
def OGMDeregister(self, global_id):
result = self._Call('msync:ogm:deregister', {'ObjectGlobalID': global_id})
return result is not None and result.get('code') == 200
# ---------- 事务 ----------
def TxCreate(self, name, steps, **kwargs):
body = {'TransactionName': name, 'Steps': steps}
for key in ('TransactionDescription', 'TransactionType', 'InitiatorServiceName',
'ReferenceOGMUUID', 'Payload', 'TimeoutSeconds'):
kwarg_key = key[0].lower() + key[1:]
if kwarg_key in kwargs:
body[key] = kwargs.pop(kwarg_key)
body.update(kwargs)
result = self._Call('msync:tx:create', body, timeout=30)
if result and result.get('code') == 200:
return result.get('data')
return None
def TxExecute(self, transaction_code, access_token=None):
result = self._Call('msync:tx:execute', {
'TransactionCode': transaction_code,
'AccessToken': access_token,
}, timeout=60)
if result and result.get('code') == 200:
return result.get('data')
return None
def TxDetail(self, transaction_code):
result = self._Call('msync:tx:detail', {'TransactionCode': transaction_code})
if result and result.get('code') == 200:
return result.get('data')
return None
# ---------- 远程调用 ----------
def CallRemote(self, service_name, method, path, data=None, json=None, **kwargs):
import requests
service_info = self.Discover(service_name)
if not service_info:
logger.error(f'目标服务未发现: {service_name}')
return None
domain = service_info.get('ServiceDomain', '')
url = f'{domain.rstrip("/")}{path}'
kwargs.setdefault('headers', {}).update(self._Headers())
kwargs.setdefault('timeout', 30)
method_func = getattr(requests, method.lower(), requests.get)
return method_func(url, data=data, json=json, **kwargs)
# ---------- 应用清单上报(远端 App 注入) ----------
def ReportAppManifests(self, manifests, service_domain=None):
"""上报应用清单到主服务器(远端 App 注入 GERP 菜单机制)
Args:
manifests: 清单 dict 列表,结构与主服务器 AppManifest 一致:
{AppCode, AppName, AppIcon, AppDesc, AppType, FrontendRoute,
FrontendType, LegacySrc, Menus: [{MenuName, MenuTitle, MenuLink,
MenuIcon, MenuSort, ParentMenuName}], ConfigKeys: []}
service_domain: 本服务对外域名(缺省用注册时 ServiceDomain,
页面 iframe 加载 base)
Returns:
(ok, result):ok=True 时 result={Registered:[], Failed:[]};
ok=False 时 result 为原始响应 dict
"""
body = {
'ServiceName': self.ServiceName,
'ServiceDomain': service_domain or self.ServiceDomain,
'Manifests': manifests,
}
result = self._Call('msync:app:manifest:report', body, timeout=30)
if result and result.get('code') == 200:
return True, result.get('data', {})
return False, result
+23
View File
@@ -0,0 +1,23 @@
# ATestCServer 子服务器依赖
# 安装顺序:先装 SDK,再装项目依赖
# 1) pip install -e ../gvsdsdk
# 2) pip install -r requirements.txt
# === GVS-DSDK(双端共享,必须装,**版本与主服务器严格一致**) ===
gvsdsdk==1.0.0
# === Django 核心 ===
django>=4.2,<5.0
djangorestframework>=3.14,<4.0
# === 数据库(连接 SAAS 主库 MySQL) ===
mysqlclient>=2.2
# === MSYNC Socket.IO 客户端(子服务器需要) ===
python-socketio[client]>=5.0,<6.0
websocket-client>=1.5
requests>=2.28
# === 加密(与主服务器同版本) ===
pyOpenSSL>=23.0
pycryptodome>=3.18
+35
View File
@@ -0,0 +1,35 @@
# ATestCServer 启动脚本
# HTTP 端口 25080,HTTPS 端口 25443
#
# 用法:
# .\start.ps1 # 普通启动
# .\start.ps1 -Migrate # 带数据库迁移
param(
[switch]$Migrate = $false
)
$ErrorActionPreference = "Stop"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $ScriptDir
Write-Host "============================" -ForegroundColor Cyan
Write-Host " ATestCServer 子服务器启动" -ForegroundColor Cyan
Write-Host " HTTP : http://127.0.0.1:25080" -ForegroundColor Green
Write-Host " HTTPS : https://127.0.0.1:25443" -ForegroundColor Green
Write-Host " MSYNC → https://www.gvsds.com" -ForegroundColor Yellow
Write-Host " (父) → http://127.0.0.1:25080 (本地回环)" -ForegroundColor Yellow
Write-Host "============================" -ForegroundColor Cyan
$env:PYTHONPATH = "$ScriptDir;$ScriptDir\..\gvsdsdk"
if ($Migrate) {
Write-Host "[*] 执行数据库迁移..." -ForegroundColor Magenta
python manage.py makemigrations sub_server
python manage.py migrate
Write-Host "[√] 迁移完成" -ForegroundColor Green
}
Write-Host "[*] 启动 Django 开发服务器..." -ForegroundColor Magenta
python manage.py runserver 0.0.0.0:25080