Files
railwaysystem/scripts/qa/qa.py
T
buliping 5675053d09 refactor(iot): 移除 MQTT 传输层,仅保留设备开放接口与门禁写操作策略
- 依赖:删除根 pom 的 paho.version 与 org.eclipse.paho.client.mqttv3 依赖声明,
  同步移除 ruoyi-iot 模块中的 Paho 依赖,模块描述改为“iot设备数据与开放接口”
- MQTT 协议栈整体删除:IotMqttConfig、IotMqttProperties、MqttClientManager、
  MqttMessageDispatcher、MqttCallbackHandler、MqttConnectionCallback、
  IMqttMessageListener、MqttMessageDTO、MqttPublishRequest、MqttMessageEvent、
  IMqttService/MqttServiceImpl、IotMqttController,以及 lock 侧 topic 订阅与
  LockMessageListener、AccessRecordHandler、CommandRespHandler、
  GatewayOnlineHandler、LockSignalHandler
- MQTT 报文留痕链路删除:LockMqttLog 实体、LockMqttLogMapper(+XML)、
  ILockMqttLogService/LockMqttLogServiceImpl、LockMqttLogController,
  以及对应的前端页面 build_ui/src/views/iot/mqttlog 与 API build_ui/src/api/iot/mqttlog.js
- 命令下发改为仅落库:LockCommandServiceImpl 派发时直接置 TRANSPORT_DISABLED 失败,
  retryPending 返回 0;删除 GatewayOfflineCheckTask、LockCommandRetryTask、
  LockDeviceOfflineCheckTask 等依赖 MQTT 的补偿与离线检查定时任务
- 保留与传输无关的门禁写操作策略:新增 IotLockSecurityProperties(iot.lock-security),
  RealIotSecurityGate 及其测试改用该配置类
- 配置与调用处清理:application*.yml 中 iot.mqtt 段迁移为 iot.lock-security,
  删除 broker-url/client-id/username/password/topics/will/qos 等连接参数,
  修正 iot.face 环境标识注释引用;QA 工具启动参数改用 --iot.lock-security.*,
  并移除 browser_regression.js 的 mqttlog 路由
- 测试同步:删除依赖 MQTT 与传输层的 CommandRespHandlerMockTest、
  Round5sEgressIsolationTest、Round5sExecutionIntegrationTest、
  Round5v4BrowserFixture、Round5v4KingbaseIntegrationTest;
  更新 RealIotSecurityGateTest、FaceExtractClientMockTest、
  TrainIotLockAuthBridgeMockTest

说明:数据库侧 lock_mqtt_log 表结构与历史验收文档保留,未做数据迁移;
前端若仍配置了“MQTT报文日志”菜单,需在后台菜单管理中一并移除。
2026-11-10 16:06:23 +08:00

281 lines
14 KiB
Python

#!/usr/bin/env python3
"""Local QA only. No DDL, hardware, account provisioning or shared-service management."""
import argparse
import base64
import hashlib
import json
import os
from pathlib import Path
import secrets
import shlex
import signal
import shutil
import socket
import stat
import subprocess
import sys
import time
import urllib.parse
import xml.etree.ElementTree as ET
import qa_isolation
ROOT = Path(__file__).resolve().parents[2]
DATABASE = 'railwaysystem_fusion_qa_20261013'
RUNTIME = ROOT / '.qa-runtime'
JAR = Path.home() / '.m2/repository/cn/com/kingbase/kingbase8/9.0.0/kingbase8-9.0.0.jar'
def load_config(path):
path = path.expanduser()
info = path.stat()
if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077 or not stat.S_ISREG(info.st_mode):
raise ValueError('Config must be an owner-only regular file (0600)')
values = {}
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith('#'):
continue
key, value = line.removeprefix('export ').split('=', 1)
tokens = shlex.split(value, comments=True)
if len(tokens) != 1:
raise ValueError('Config requires literal KEY=value; no shell expansion')
values[key] = tokens[0]
return values
def environment(config):
# Legacy QA_DB_URL deliberately NOT reused: it may point to a different QA database.
url = config.get('FUSION_QA_DB_URL', 'jdbc:kingbase8://192.168.139.218:4321/' + DATABASE + '?connectTimeout=5&socketTimeout=15')
parsed = urllib.parse.urlsplit(url.removeprefix('jdbc:'))
if parsed.scheme != 'kingbase8' or parsed.path != '/' + DATABASE or parsed.username or parsed.password or parsed.fragment:
raise ValueError('Refusing non-target database URL')
if any(k.lower() in ('user', 'password') for k in urllib.parse.parse_qs(parsed.query)):
raise ValueError('URL credentials forbidden')
if parsed.port == qa_isolation.LEGACY_PORT:
raise ValueError('Legacy database endpoint is forbidden')
password = config.get('FUSION_QA_APP_PASSWORD', '')
if not password:
raise ValueError('Missing FUSION_QA_APP_PASSWORD')
inherited = {key: value for key, value in os.environ.items()
if not key.startswith(('SPRING_', 'IOT_', 'DMS_', 'QA_', 'TOKEN_', 'TRAIN_'))
and key not in ['JAVA_TOOL_OPTIONS', 'JDK_JAVA_OPTIONS', '_JAVA_OPTIONS']}
return dict(inherited, QA_DB_URL=url, QA_DB_USER='fusion_qa_app', QA_DB_PASSWORD=password)
def verify_snapshot(directory):
directory = directory.resolve()
if not directory.is_relative_to(RUNTIME.resolve()):
raise ValueError('Snapshot must stay inside this task private runtime directory')
expected = json.loads((directory / 'sha256.json').read_text())
for name in ['lock_auth.xml', 'lock_command.xml']:
raw = (directory / name).read_bytes()
if hashlib.sha256(raw).hexdigest() != expected[name]:
raise ValueError('Snapshot checksum mismatch')
tree = ET.fromstring(raw)
rows = sum(1 for node in tree.iter() if node.tag.rsplit('}', 1)[-1] in ['currentRow', 'insertRow'])
print('SNAPSHOT_PARSE_SHA256_VERIFIED ' + name + ' rows=' + str(rows))
print('NATIVE_DATABASE_RESTORE_NOT_VERIFIED')
def private_directory():
RUNTIME.mkdir(mode=0o700, exist_ok=True)
if RUNTIME.is_symlink() or RUNTIME.stat().st_uid != os.getuid() or stat.S_IMODE(RUNTIME.stat().st_mode) & 0o077:
raise ValueError('Unsafe runtime directory')
def inspect(env, extra):
command = ['java', '-cp', str(JAR), str(ROOT / 'scripts/qa/QaDatabaseProbe.java'), *extra]
result = subprocess.run(command, env=env, text=True, capture_output=True, timeout=60)
private_directory()
path = RUNTIME / 'database-inspection.log'
with open(path, 'w', opener=lambda p, f: os.open(p, f, 0o600)) as stream:
stream.write(result.stdout + result.stderr)
# Probe reports only metadata/counts; SQL errors are reduced to SQLSTATE.
print(result.stdout + result.stderr, end='')
return result.returncode
def available(port):
with socket.socket() as probe:
probe.bind(('127.0.0.1', port))
def druid_url(url):
# Druid 1.2.28 also parses URL timeout values as pool milliseconds, whereas
# Kingbase JDBC uses seconds. Keep pool timeout configuration in one place.
parsed = urllib.parse.urlsplit(url.removeprefix('jdbc:'))
query = [(k,v) for k,v in urllib.parse.parse_qsl(parsed.query)
if k.lower() not in ('connecttimeout', 'sockettimeout')]
return 'jdbc:' + urllib.parse.urlunsplit(parsed._replace(query=urllib.parse.urlencode(query)))
def launch(name, command, env):
private_directory()
ledger = RUNTIME / (name + '.json')
if ledger.exists():
raise ValueError('Runtime ledger exists; inspect/stop this task-owned process first')
log = open(RUNTIME / (name + '.log'), 'w', opener=lambda p, f: os.open(p, f, 0o600))
child = subprocess.Popen(command, cwd=ROOT, env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
log.close()
started = subprocess.run(['ps', '-p', str(child.pid), '-o', 'lstart='], text=True, capture_output=True).stdout.strip()
ledger.write_text(json.dumps({'pid': child.pid, 'command': command, 'cwd': str(ROOT), 'started': started,
'isolation_id': env.get('FUSION_QA_ISOLATION_ID')}))
print(name + ' PID=' + str(child.pid) + ' LOG=' + str(RUNTIME / (name + '.log')))
def start(config, env, backend_port, redis_port):
lease = qa_isolation.require(env.get('QA_DB_URL'))
env['FUSION_QA_ISOLATION_ID'] = lease['isolation_id']
if inspect(env, ['--require-migrated']):
raise ValueError('Backend not started: QA migration preflight failed')
available(backend_port)
available(redis_port)
private_directory()
built_jar = ROOT / 'RuoYi-Vue/ruoyi-admin/target/ruoyi-admin.jar'
if not built_jar.is_file():
raise ValueError('Build backend jar first')
digest = hashlib.sha256(built_jar.read_bytes()).hexdigest()
runtime_jar = RUNTIME / ('ruoyi-admin-' + digest[:16] + '.jar')
if not runtime_jar.exists():
shutil.copyfile(built_jar, runtime_jar)
if hashlib.sha256(runtime_jar.read_bytes()).hexdigest() != digest:
raise ValueError('Runtime artifact hash mismatch')
secret_path = RUNTIME / 'runtime-secrets.env'
if not secret_path.exists():
with open(secret_path, 'x', opener=lambda p, f: os.open(p, f, 0o600)) as f:
f.write('TOKEN_SECRET=' + base64.b64encode(secrets.token_bytes(64)).decode() + '\n')
f.write('QA_REDIS_PASSWORD=' + secrets.token_urlsafe(40) + '\n')
local_secrets = load_config(secret_path)
env.update(local_secrets)
for key in ['TRAIN_STUDENT_IDENTITY_ENCRYPTION_KEY', 'TRAIN_STUDENT_IDENTITY_HASH_KEY']:
if not config.get(key):
raise ValueError('Missing required protected identity configuration')
env[key] = config[key]
runtime_url = druid_url(env['QA_DB_URL'])
env.update(SPRING_DATASOURCE_DRUID_MASTER_URL=runtime_url,
SPRING_DATASOURCE_DRUID_MASTER_USERNAME='fusion_qa_app',
SPRING_DATASOURCE_DRUID_MASTER_PASSWORD=env['QA_DB_PASSWORD'],
SPRING_DATA_REDIS_PASSWORD=env['QA_REDIS_PASSWORD'])
redis_config = RUNTIME / 'redis.conf'
with open(redis_config, 'w', opener=lambda p, f: os.open(p, f, 0o600)) as f:
f.write('bind 127.0.0.1\nprotected-mode yes\nport ' + str(redis_port) + '\nsave ""\nappendonly no\nrequirepass ' + env['QA_REDIS_PASSWORD'] + '\n')
command = ['java', '-jar', str(runtime_jar),
'--spring.profiles.active=druid',
'--spring.datasource.druid.master.url=' + runtime_url,
'--spring.datasource.druid.master.username=fusion_qa_app',
'--spring.datasource.druid.master.connect-timeout=30000',
'--spring.datasource.druid.master.socket-timeout=60000',
'--spring.datasource.druid.master.initial-size=2',
'--spring.datasource.druid.master.min-idle=2',
'--spring.datasource.druid.master.max-active=8',
'--server.address=127.0.0.1', '--server.port=' + str(backend_port),
'--spring.data.redis.host=127.0.0.1', '--spring.data.redis.port=' + str(redis_port),
'--spring.data.redis.database=0', '--spring.quartz.auto-startup=false',
'--ruoyi.profile=' + str(RUNTIME / 'upload'), '--logging.file.path=' + str(RUNTIME / 'logs'),
'--logging.level.com.ruoyi=INFO', '--iot.lock-security.mock=true', '--iot.lock-security.enabled=false',
'--iot.lock-security.real-device-write-enabled=false', '--iot.lock-security.device-whitelist=',
'--iot.lock-security.environment-id=round5v-local-mock', '--iot.lock-security.protocol-verified=false',
'--iot.qa-api-enabled=false', '--iot.openapi.enabled=false',
'--iot.face.mock=true', '--iot.face.enabled=false', '--iot.face.real-http-enabled=false',
'--iot.face.allowed-endpoints=', '--dms.enabled=false', '--dms.mock=true',
'--spring.datasource.druid.stat-view-servlet.enabled=false']
if not Path(command[2]).is_file():
raise ValueError('Build backend jar first')
launch('redis', ['redis-server', str(redis_config)], env)
launch('backend', command, env)
def wait_for_exit(pid):
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
state = subprocess.run(['ps', '-p', str(pid), '-o', 'stat='], text=True, capture_output=True).stdout.strip()
if not state or state.startswith('Z'):
return
time.sleep(0.1)
raise ValueError('Task-owned process did not stop; ledger retained, no forced kill')
def stop(isolation_id=None):
private_directory()
for name in ['frontend', 'backend', 'redis']:
path = RUNTIME / (name + '.json')
if not path.exists():
continue
item = json.loads(path.read_text())
if isolation_id is not None and item.get('isolation_id') != isolation_id:
raise ValueError('Other isolation lease owns this runtime ledger; refusing to signal it')
actual = subprocess.run(['ps', '-p', str(item['pid']), '-o', 'command='], text=True, capture_output=True).stdout.strip()
if actual:
# macOS Python wrappers exec Python.app; Redis rewrites its process title.
# Check task arguments + cwd + birth time, never select by port/name alone.
suffix = ' '.join(item['command'][1:])
cwd = subprocess.run(['lsof', '-a', '-p', str(item['pid']), '-d', 'cwd', '-Fn'], text=True, capture_output=True).stdout
started = subprocess.run(['ps', '-p', str(item['pid']), '-o', 'lstart='], text=True, capture_output=True).stdout.strip()
comm = subprocess.run(['ps', '-p', str(item['pid']), '-o', 'comm='], text=True, capture_output=True).stdout.strip()
identity = actual.endswith(suffix) and suffix != ''
if name == 'redis':
identity = bool(item.get('started')) and Path(comm).name.startswith('redis-server')
if not identity or ('n' + str(ROOT)) not in cwd.splitlines() or (item.get('started') and item['started'] != started):
raise ValueError('PID identity mismatch; refusing to signal ' + name)
os.kill(item['pid'], signal.SIGTERM)
wait_for_exit(item['pid'])
path.unlink()
print('Stopped task-owned ' + name)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=['inspect', 'snapshot', 'verify-snapshot', 'start', 'frontend', 'stop'])
parser.add_argument('--config', type=Path, default=Path.home() / '.railwaysystem-qa.env')
parser.add_argument('--backend-port', type=int, default=18096)
parser.add_argument('--frontend-port', type=int, default=18088)
parser.add_argument('--redis-port', type=int, default=16389)
parser.add_argument('--snapshot-dir', type=Path)
args = parser.parse_args()
if args.action == 'verify-snapshot':
if not args.snapshot_dir:
raise ValueError('Specify --snapshot-dir')
verify_snapshot(args.snapshot_dir)
return 0
if args.action == 'stop':
stop()
return 0
if args.action == 'frontend':
lease = qa_isolation.require()
backend = json.loads((RUNTIME / 'backend.json').read_text())
if backend.get('isolation_id') != lease['isolation_id'] or '--server.port=' + str(args.backend_port) not in backend.get('command', []):
raise ValueError('Frontend backend belongs to another isolation lease')
available(args.frontend_port)
env = os.environ.copy()
env['FUSION_QA_ISOLATION_ID'] = lease['isolation_id']
launch('frontend', [sys.executable, str(ROOT / 'scripts/qa/serve_frontend.py'),
'--port', str(args.frontend_port), '--backend-port', str(args.backend_port)], env)
return 0
config = load_config(args.config)
if args.action == 'start' or (RUNTIME / qa_isolation.LEDGER).exists():
lease = qa_isolation.require()
config.setdefault('FUSION_QA_DB_URL', qa_isolation.url_for(lease))
env = environment(config)
if args.action == 'inspect':
return inspect(env, [])
if args.action == 'snapshot':
private_directory()
path = RUNTIME / ('snapshot-' + secrets.token_hex(6))
rc = inspect(env, ['--backup', str(path)])
if rc == 0:
hashes = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in path.glob('*.xml')}
(path / 'sha256.json').write_text(json.dumps(hashes, indent=2))
print('SNAPSHOT_DIRECTORY=' + str(path))
return rc
start(config, env, args.backend_port, args.redis_port)
return 0
if __name__ == '__main__':
try:
sys.exit(main())
except (ValueError, OSError, subprocess.TimeoutExpired) as error:
# Do not echo environment or configuration values.
print('QA_OPERATION_REFUSED: ' + (str(error) if isinstance(error, ValueError) else type(error).__name__), file=sys.stderr)
sys.exit(2)