提交 271ab62c authored 作者: 陈泽健's avatar 陈泽健

docs(prd): 更新ARM自动更新部署包版本需求文档并新增X86环境更新需求,同步更新shell版本服务自检

- 修复ARM部署包需求文档中auth包路径错误
- 修复会议预定前端服务目录路径中的arn_offline_auto拼写错误
- 更新网盘目录路径从X86部署包改为ARM部署包
- 添加代码架构说明及共用脚本注意事项
- 新增X86自动更新环境版本需求文档
- 移除PowerShell脚本中被禁用的Shell模式选择选项
- 添加Shell脚本版配置文件与日志导出功能
上级 a5909acd
{
"test_server": {
"host": "192.168.5.44",
"port": 22,
"username": "root",
"password": "Ubains@123"
},
"target_servers": [
{
"name": "其他环境服务器",
"host": "192.168.5.46",
"port": 22,
"username": "root",
"password": "Ubains@123"
}
],
"services": {
"frontend": [
{"name": "ai包", "path": "web/pc/pc-vue2-ai", "config_file": "static/config.json"},
{"name": "后台包", "path": "web/pc/pc-vue2-backstage", "config_file": "static/config.json"},
{"name": "main包", "path": "web/pc/pc-vue2-main", "config_file": "static/config.json"},
{"name": "meetngV2包", "path": "web/pc/pc-vue2-meetngV2", "config_file": "static/config.json"},
{"name": "meetngV3包", "path": "web/pc/pc-vue2-meetngV3", "config_file": "static/config.json"},
{"name": "meetingControl包", "path": "web/pc/pc-vue2-meetingControl", "config_file": "static/config.json"},
{"name": "monitor包", "path": "web/pc/pc-vue2-moniter", "config_file": "static/config.json"},
{"name": "platform包", "path": "web/pc/pc-vue2-platform", "config_file": "static/config.json"},
{"name": "voice包", "path": "web/pc/pc-vue2-voice/pc-vue2-voice", "config_file": "static/config.json"},
{"name": "h5-meeting", "path": "web/h5/h5-uniapp-meeting", "config_file": "static/config.json"},
{"name": "h5-moniter", "path": "web/h5/h5-uniapp-moniter", "config_file": "static/config.json"},
{"name": "h5-platform-mobile", "path": "web/h5/h5-uniapp-platform/meeting-mobile", "config_file": "static/config.json"},
{"name": "h5-platform-platform-mobile", "path": "web/h5/h5-uniapp-platform/unified-platform-mobile", "config_file": "static/config.json"}
],
"backend_jar": [
{"name": "auth包", "path": "api/auth/auth-sso-auth", "file": "ubains-auth.jar"},
{"name": "gatway包", "path": "api/auth/auth-sso-gatway", "file": "ubains-gateway.jar"},
{"name": "system包", "path": "api/auth/auth-sso-system", "file": "ubains-modules-system.jar"},
{"name": "java2.0包", "path": "api/java-meeting/java-meeting2.0", "file": "ubains-meeting-inner-api-1.0-SNAPSHOT.jar"},
{"name": "java-extapi包", "path": "api/java-meeting/java-meeting-extapi", "file": "ubains-meeting-api-1.0-SNAPSHOT.jar"},
{"name": "java-scheduling包", "path": "api/java-meeting/java-message-scheduling", "file": "ubains-meeting-message-scheduling-1.0-SNAPSHOT.jar"},
{"name": "java-mqtt包", "path": "api/java-meeting/java-mqtt", "file": "ubains-meeting-mqtt-1.0-SNAPSHOT.jar"},
{"name": "java-quartz包", "path": "api/java-meeting/java-quartz", "file": "ubains-meeting-quartz-1.0-SNAPSHOT.jar"}
],
"backend_folder": [
{"name": "cmdb包", "path": "api/python-cmdb", "config_file": "cmdb/bus/config/settingbus.conf"},
{"name": "voice包", "path": "api/python-voice", "config_file": "uvoice/bus/config/settingbus.conf"}
]
},
"path_mapping": {
"web/pc/pc-vue2-meetngV2": "web/pc/pc-vue2-meetingV2",
"web/pc/pc-vue2-meetngV3": "web/pc/pc-vue2-meetingV3"
},
"containers": ["ujava2", "upython", "upython_voice"],
"service_base_dir": "/data/services"
}
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
X86自动更新环境版本脚本
从测试服务器提取全量服务包,通过SCP传输到其他环境服务器,
在目标服务器上执行备份、更新替换、配置恢复、容器重启,生成更新报告。
用法:
python x86_env_update.py # 完整更新流程
python x86_env_update.py --check-only # 仅执行前置检查
python x86_env_update.py --skip-restart # 跳过容器重启步骤
python x86_env_update.py --config update_config.json # 指定配置文件
python x86_env_update.py --help # 查看帮助
"""
import sys
import os
import json
import time
import shutil
import argparse
import paramiko
from datetime import datetime
# 修复Windows控制台编码问题
if sys.platform == 'win32':
try:
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
except Exception:
pass
class X86EnvUpdate:
"""X86环境版本自动更新工具"""
def __init__(self, config_path=None):
"""初始化配置和状态
Args:
config_path: 配置文件路径,默认为脚本同目录下的 update_config.json
"""
# 加载配置文件
self.config = self._load_config(config_path)
self.test_server = self.config['test_server']
self.target_servers = self.config['target_servers']
self.services = self.config['services']
self.path_mapping = self.config.get('path_mapping', {})
self.containers = self.config.get('containers', [])
self.service_base_dir = self.config.get(
'service_base_dir', '/data/services'
)
# SSH/SFTP 客户端
self.test_ssh = None
self.test_sftp = None
# 日志和报告
self.log_file = None
self.total_start_time = None
self.report_data = {
'start_time': '',
'end_time': '',
'total_duration': '',
'status': '成功',
'target_results': [],
'errors': []
}
# 本地临时目录和报告目录
script_dir = os.path.dirname(os.path.abspath(__file__))
self.temp_dir = os.path.join(script_dir, 'temp_env_update')
self.reports_dir = os.path.join(script_dir, 'reports')
def _load_config(self, config_path=None):
"""加载配置文件
Args:
config_path: 配置文件路径
Returns:
dict: 配置字典
"""
if not config_path:
config_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
'update_config.json'
)
try:
with open(config_path, 'r', encoding='utf-8') as f:
return json.load(f)
except FileNotFoundError:
print(f"[ERROR] 配置文件不存在: {config_path}")
sys.exit(1)
except json.JSONDecodeError as e:
print(f"[ERROR] 配置文件格式错误: {e}")
sys.exit(1)
# ==================== 日志模块 ====================
def _init_log(self):
"""初始化日志文件和报告目录"""
os.makedirs(self.reports_dir, exist_ok=True)
os.makedirs(self.temp_dir, exist_ok=True)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
self.log_file = os.path.join(
self.reports_dir, f'env_update_{timestamp}.log'
)
self.log(f"日志文件: {self.log_file}")
def log(self, message, level="INFO"):
"""输出日志到控制台和文件
Args:
message: 日志消息
level: 日志级别
"""
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
log_msg = f"[{timestamp}] [{level}] {message}"
try:
print(log_msg)
except UnicodeEncodeError:
safe_msg = log_msg.encode('gbk', errors='replace').decode('gbk')
print(safe_msg)
if self.log_file:
try:
with open(self.log_file, 'a', encoding='utf-8') as f:
f.write(log_msg + '\n')
except Exception:
pass
# ==================== SSH/SFTP 连接管理 ====================
def _create_ssh_client(self, host, port, username, password, name="服务器"):
"""创建SSH连接并返回客户端和SFTP
Args:
host: 服务器IP
port: SSH端口
username: 用户名
password: 密码
name: 服务器名称(用于日志)
Returns:
tuple: (SSH客户端, SFTP客户端) 或 (None, None)
"""
self.log(f"正在连接{name} ({host}:{port})...")
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
# 重试3次连接
retry_count = 0
max_retries = 3
while retry_count < max_retries:
try:
client.connect(
hostname=host,
port=port,
username=username,
password=password,
timeout=30
)
sftp = client.open_sftp()
self.log(f"{name}连接成功")
return client, sftp
except Exception as e:
retry_count += 1
self.log(
f"{name}连接失败 (第{retry_count}次): {e}", "WARN"
)
if retry_count < max_retries:
time.sleep(10)
self.log(f"{name}连接失败,已达最大重试次数", "ERROR")
return None, None
def _exec_remote_cmd(self, ssh_client, cmd, timeout=600):
"""在指定SSH客户端上执行命令
Args:
ssh_client: SSH客户端
cmd: 要执行的命令
timeout: 超时时间(秒)
Returns:
tuple: (退出码, 标准输出, 标准错误)
"""
try:
stdin, stdout, stderr = ssh_client.exec_command(
cmd, timeout=timeout
)
out = stdout.read().decode('utf-8', errors='replace')
err = stderr.read().decode('utf-8', errors='replace')
exit_code = stdout.channel.recv_exit_status()
return exit_code, out, err
except Exception as e:
self.log(f"远程命令执行异常: {cmd}, 错误: {e}", "ERROR")
return -1, '', str(e)
def _disconnect_all(self):
"""断开测试服务器SSH连接"""
if self.test_sftp:
try:
self.test_sftp.close()
except Exception:
pass
if self.test_ssh:
try:
self.test_ssh.close()
except Exception:
pass
self.log("已断开测试服务器连接")
# ==================== 工具函数 ====================
def _format_size(self, size_bytes):
"""格式化文件大小
Args:
size_bytes: 字节数
Returns:
str: 格式化后的大小字符串
"""
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
elif size_bytes < 1024 * 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.1f} MB"
else:
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
def _get_remote_file_size(self, ssh_client, filepath):
"""获取远程文件或目录大小
Args:
ssh_client: SSH客户端
filepath: 远程文件路径
Returns:
int: 文件大小(字节)
"""
stdin, stdout, stderr = ssh_client.exec_command(
f'stat -c %s "{filepath}" 2>/dev/null || echo "0"'
)
size = stdout.read().decode().strip()
return int(size) if size.isdigit() else 0
def _get_mapped_path(self, path):
"""获取映射后的路径(测试服务器路径 → 目标服务器路径)
Args:
path: 测试服务器相对路径
Returns:
str: 目标服务器相对路径
"""
return self.path_mapping.get(path, path)
# ==================== 阶段一:前置检查 ====================
def phase1_pre_check(self):
"""阶段一:前置检查
包括SSH连接、磁盘空间、Docker状态检查
Returns:
bool: 检查是否全部通过
"""
self.log("=" * 60)
self.log("阶段一:前置检查")
self.log("=" * 60)
# 步骤1.1:连接测试服务器
self.test_ssh, self.test_sftp = self._create_ssh_client(
self.test_server['host'],
self.test_server['port'],
self.test_server['username'],
self.test_server['password'],
"测试服务器"
)
if not self.test_ssh:
self.log("测试服务器连接失败,终止执行", "ERROR")
return False
# 步骤1.2:连接目标服务器并检查
for target in self.target_servers:
name = target['name']
host = target['host']
# 连接目标服务器
target['ssh'], target['sftp'] = self._create_ssh_client(
host,
target.get('port', 22),
target['username'],
target['password'],
f"目标服务器-{name}"
)
if not target['ssh']:
self.log(f"目标服务器 {name} ({host}) 连接失败", "ERROR")
return False
ssh = target['ssh']
# 步骤1.3:磁盘空间检查(至少需要5GB)
self.log(f"检查 {name} ({host}) 磁盘空间...")
exit_code, out, err = self._exec_remote_cmd(
ssh,
f'df -m "{self.service_base_dir}" | tail -1 '
f'| awk \'{{print $4}}\''
)
avail_mb = out.strip()
if avail_mb.isdigit() and int(avail_mb) < 5120:
self.log(
f"{name} 磁盘空间不足: 可用 {avail_mb}MB, "
f"需要至少 5120MB",
"ERROR"
)
return False
self.log(f" {name} 磁盘空间: 可用 {avail_mb}MB ✅")
# 步骤1.4:Docker服务检查
self.log(f"检查 {name} ({host}) Docker服务...")
exit_code, out, err = self._exec_remote_cmd(
ssh, "docker info >/dev/null 2>&1 && echo OK"
)
if "OK" not in out:
self.log(
f"{name} Docker服务异常,请检查Docker是否运行",
"ERROR"
)
return False
self.log(f" {name} Docker服务正常 ✅")
self.log("阶段一:前置检查全部通过 ✅")
return True
# ==================== 阶段二:服务包打包与传输 ====================
def phase2_package_and_transfer(self, target):
"""阶段二:从测试服务器打包服务并传输到目标服务器
Args:
target: 目标服务器配置字典
Returns:
bool: 是否成功
"""
target_name = target['name']
target_host = target['host']
target_ssh = target['ssh']
self.log("-" * 60)
self.log(f"阶段二:打包服务并传输到 {target_name} ({target_host})")
self.log("-" * 60)
# 收集所有待打包的目录路径
all_dirs = []
for svc in self.services['frontend']:
all_dirs.append(svc['path'])
for svc in self.services['backend_jar']:
all_dirs.append(svc['path'])
for svc in self.services['backend_folder']:
all_dirs.append(svc['path'])
# 步骤2.1:验证测试服务器上的服务目录
self.log("验证测试服务器上的服务目录...")
dirs_str = ' '.join(all_dirs)
check_cmd = (
f'cd {self.service_base_dir} && '
f'for d in {dirs_str}; do '
f'[ -d "$d" ] && echo "EXISTS:$d" '
f'|| echo "MISSING:$d"; done'
)
exit_code, out, err = self._exec_remote_cmd(
self.test_ssh, check_cmd, timeout=60
)
existing_dirs = []
missing_dirs = []
for line in out.strip().split('\n'):
if not line.strip():
continue
if line.startswith('EXISTS:'):
existing_dirs.append(line.replace('EXISTS:', ''))
elif line.startswith('MISSING:'):
missing_dirs.append(line.replace('MISSING:', ''))
if missing_dirs:
self.log(
f"测试服务器上以下目录不存在,将跳过: {missing_dirs}",
"WARN"
)
if not existing_dirs:
self.log(
"测试服务器上没有任何目标服务目录存在", "ERROR"
)
return False
self.log(f"找到 {len(existing_dirs)}/{len(all_dirs)} 个有效服务目录")
# 步骤2.2:在测试服务器上打包所有服务
remote_tmp_tar = '/tmp/services_update.tar.gz'
self._exec_remote_cmd(self.test_ssh, f'rm -f "{remote_tmp_tar}"')
existing_dirs_str = ' '.join(existing_dirs)
self.log("正在测试服务器上打包所有服务(请勿中断)...")
tar_cmd = (
f'cd {self.service_base_dir} && '
f'tar --warning=no-file-changed -czf "{remote_tmp_tar}" '
f'{existing_dirs_str}'
)
exit_code, out, err = self._exec_remote_cmd(
self.test_ssh, tar_cmd, timeout=1800
)
# tar退出码1表示文件在打包过程中被修改,仍然正常
if exit_code not in (0, 1):
self.log(f"测试服务器打包失败: {err}", "ERROR")
return False
# 获取打包文件大小
tar_size = self._get_remote_file_size(self.test_ssh, remote_tmp_tar)
self.log(f"服务包打包完成,大小: {self._format_size(tar_size)}")
# 记录到目标结果
target_result = self._get_target_result(target)
target_result['package_size'] = self._format_size(tar_size)
# 步骤2.3:传输到目标服务器
# 优先尝试服务器间SSH管道直传
build_tmp_tar = '/data/services_update.tar.gz'
self._exec_remote_cmd(target_ssh, f'rm -f "{build_tmp_tar}"')
# 检查测试服务器到目标服务器的SSH连通性
self.log("检查服务器间SSH连通性...")
test_ssh_cmd = (
f'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 '
f'{target["username"]}@{target_host} '
f'\'echo OK\' 2>/dev/null'
)
exit_code, out, err = self._exec_remote_cmd(
self.test_ssh, test_ssh_cmd, timeout=15
)
transferred = False
if "OK" in out:
# 服务器间SSH互通,使用管道直传
self.log("服务器间SSH互通,使用管道直传...")
pipe_cmd = (
f'cd {self.service_base_dir} && '
f'tar --warning=no-file-changed -czf - '
f'{existing_dirs_str} '
f'| ssh -o StrictHostKeyChecking=no '
f'{target["username"]}@{target_host} '
f'\'cat > {build_tmp_tar}\''
)
exit_code, out, err = self._exec_remote_cmd(
self.test_ssh, pipe_cmd, timeout=3600
)
if exit_code == 0:
self.log("服务器间直传完成")
transferred = True
else:
self.log(
f"管道传输失败: {err},回退到本地中转", "WARN"
)
if not transferred:
# 回退方式:通过本地机器中转
self.log("使用本地中转方式传输...")
local_tar = os.path.join(
self.temp_dir, 'services_update.tar.gz'
)
# 从测试服务器下载到本地
self.log("正在从测试服务器下载到本地...")
try:
self.test_sftp.get(remote_tmp_tar, local_tar)
self.log("下载完成")
except Exception as e:
self.log(f"下载服务包失败: {e}", "ERROR")
return False
# 从本地上传到目标服务器
self.log("正在从本地上传到目标服务器...")
try:
target['sftp'].put(local_tar, build_tmp_tar)
self.log("上传完成")
except Exception as e:
self.log(f"上传服务包到目标服务器失败: {e}", "ERROR")
return False
# 清理本地临时文件
if os.path.exists(local_tar):
os.remove(local_tar)
# 步骤2.4:清理测试服务器临时文件
self._exec_remote_cmd(
self.test_ssh, f'rm -f "{remote_tmp_tar}"'
)
self.log("已清理测试服务器临时打包文件")
# 验证目标服务器上的文件
remote_size = self._get_remote_file_size(target_ssh, build_tmp_tar)
self.log(
f"目标服务器接收完成,文件大小: {self._format_size(remote_size)}"
)
self.log(f"阶段二:服务包传输到 {target_name} 完成 ✅")
return True
# ==================== 阶段三:备份原服务文件 ====================
def phase3_backup_services(self, target):
"""阶段三:备份目标服务器上的原服务文件
Args:
target: 目标服务器配置字典
Returns:
bool: 是否成功
"""
target_name = target['name']
target_ssh = target['ssh']
self.log("-" * 60)
self.log(f"阶段三:备份 {target_name} 原服务文件")
self.log("-" * 60)
# 创建带时间戳的备份目录
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_dir = f"/data/services_backup_{timestamp}"
self._exec_remote_cmd(target_ssh, f'mkdir -p "{backup_dir}"')
# 记录备份目录到目标结果(用于报告和回滚)
target_result = self._get_target_result(target)
target_result['backup_dir'] = backup_dir
target_result['backup_time'] = (
datetime.now().strftime('%Y-%m-%d %H:%M:%S')
)
self.log(f"备份目录: {backup_dir}")
base_dir = self.service_base_dir
# 备份前端服务目录
self.log("--- 备份前端服务 ---")
for svc in self.services['frontend']:
mapped_path = self._get_mapped_path(svc['path'])
src_dir = f"{base_dir}/{mapped_path}"
dst_dir = f"{backup_dir}/{mapped_path}"
self.log(f" 备份前端: {svc['name']}")
self._exec_remote_cmd(
target_ssh,
f'mkdir -p "{dst_dir}" && '
f'cp -r "{src_dir}/." "{dst_dir}/" 2>/dev/null && '
f'echo BACKUP_OK || echo BACKUP_SKIP'
)
# 备份后端jar文件
self.log("--- 备份后端jar服务 ---")
for svc in self.services['backend_jar']:
mapped_path = self._get_mapped_path(svc['path'])
jar_file = svc['file']
src_file = f"{base_dir}/{mapped_path}/{jar_file}"
dst_dir = f"{backup_dir}/{mapped_path}"
self.log(f" 备份jar: {svc['name']}")
self._exec_remote_cmd(
target_ssh,
f'mkdir -p "{dst_dir}" && '
f'cp -f "{src_file}" "{dst_dir}/" 2>/dev/null && '
f'echo BACKUP_OK || echo BACKUP_SKIP'
)
# 备份后端文件夹服务
self.log("--- 备份后端文件夹服务 ---")
for svc in self.services['backend_folder']:
mapped_path = self._get_mapped_path(svc['path'])
src_dir = f"{base_dir}/{mapped_path}"
dst_dir = f"{backup_dir}/{mapped_path}"
self.log(f" 备份文件夹: {svc['name']}")
self._exec_remote_cmd(
target_ssh,
f'mkdir -p "{dst_dir}" && '
f'cp -r "{src_dir}/." "{dst_dir}/" 2>/dev/null && '
f'echo BACKUP_OK || echo BACKUP_SKIP'
)
# 验证备份完整性:检查备份目录文件数量
exit_code, out, err = self._exec_remote_cmd(
target_ssh,
f'find "{backup_dir}" -type f | wc -l'
)
backup_file_count = out.strip()
self.log(f"备份文件数量: {backup_file_count}")
self.log(f"阶段三:{target_name} 备份完成 ✅")
return True
# ==================== 阶段四:服务包更新替换与配置恢复 ====================
def phase4_update_and_restore(self, target):
"""阶段四:更新服务包并恢复配置文件
Args:
target: 目标服务器配置字典
Returns:
bool: 是否成功
"""
target_name = target['name']
target_ssh = target['ssh']
target_result = self._get_target_result(target)
backup_dir = target_result.get('backup_dir', '')
self.log("-" * 60)
self.log(f"阶段四:更新 {target_name} 服务并恢复配置")
self.log("-" * 60)
base_dir = self.service_base_dir
update_dir = '/data/services_update'
# 步骤4.1:解压服务包
self.log("解压服务包到临时目录...")
self._exec_remote_cmd(target_ssh, f'rm -rf "{update_dir}"')
self._exec_remote_cmd(target_ssh, f'mkdir -p "{update_dir}"')
exit_code, out, err = self._exec_remote_cmd(
target_ssh,
f'tar -xzf /data/services_update.tar.gz -C "{update_dir}"',
timeout=1800
)
if exit_code != 0:
self.log(f"解压服务包失败: {err}", "ERROR")
return False
self.log("解压完成")
# 步骤4.2:路径映射重命名
if self.path_mapping:
self.log("执行路径映射重命名...")
for test_path, target_path in self.path_mapping.items():
old_dir = f"{update_dir}/{test_path}"
new_dir = f"{update_dir}/{target_path}"
exit_code, out, err = self._exec_remote_cmd(
target_ssh,
f'if [ -d "{old_dir}" ]; then '
f'mv "{old_dir}" "{new_dir}" && echo RENAMED; fi'
)
if "RENAMED" in out:
self.log(f" 路径映射: {test_path} -> {target_path}")
else:
self.log(
f" 路径映射跳过: {test_path} (目录不存在)",
"WARN"
)
self.log("路径映射完成")
# 步骤4.3-4.4:更新前端服务并恢复config.json
self.log("--- 更新前端服务 ---")
for svc in self.services['frontend']:
svc_name = svc['name']
mapped_path = self._get_mapped_path(svc['path'])
src_dir = f"{update_dir}/{mapped_path}"
dst_dir = f"{base_dir}/{mapped_path}"
config_file = f"{dst_dir}/{svc['config_file']}"
self.log(f"正在更新前端服务: {svc_name}")
# 记录原始大小
old_size = self._get_remote_file_size(target_ssh, dst_dir)
# 覆盖服务目录
exit_code, out, err = self._exec_remote_cmd(
target_ssh,
f'rm -rf "{dst_dir}" && cp -r "{src_dir}" "{dst_dir}"'
)
if exit_code != 0:
msg = f"覆盖前端服务 {svc_name} 失败: {err}"
self.log(msg, "ERROR")
target_result['errors'].append(
{'service': svc_name, 'error': msg}
)
return False
# 从备份恢复config.json
backup_config = f"{backup_dir}/{mapped_path}/{svc['config_file']}"
exit_code, out, err = self._exec_remote_cmd(
target_ssh,
f'if [ -f "{backup_config}" ]; then '
f'mkdir -p "$(dirname "{config_file}")" && '
f'cp -f "{backup_config}" "{config_file}" && '
f'echo RESTORED; else echo NO_CONFIG; fi'
)
if "RESTORED" in out:
self.log(f" 已恢复 {svc['config_file']}")
else:
self.log(
f" 无需恢复 {svc['config_file']}(备份中不存在)",
"WARN"
)
# 记录新大小
new_size = self._get_remote_file_size(target_ssh, dst_dir)
target_result['service_details'].append({
'name': svc_name,
'type': '前端',
'path': mapped_path,
'old_size': self._format_size(old_size),
'new_size': self._format_size(new_size),
'status': '成功'
})
self.log(f" {svc_name} 更新成功")
# 步骤4.5:更新jar后端服务(直接覆盖)
self.log("--- 更新jar后端服务 ---")
for svc in self.services['backend_jar']:
svc_name = svc['name']
mapped_path = self._get_mapped_path(svc['path'])
jar_file = svc['file']
src_file = f"{update_dir}/{mapped_path}/{jar_file}"
dst_file = f"{base_dir}/{mapped_path}/{jar_file}"
self.log(f"正在更新后端服务: {svc_name} ({jar_file})")
# 记录原始大小
old_size = self._get_remote_file_size(target_ssh, dst_file)
# 直接覆盖jar文件
exit_code, out, err = self._exec_remote_cmd(
target_ssh,
f'mkdir -p "$(dirname "{dst_file}")" && '
f'cp -f "{src_file}" "{dst_file}"'
)
if exit_code != 0:
msg = f"覆盖jar服务 {svc_name} 失败: {err}"
self.log(msg, "ERROR")
target_result['errors'].append(
{'service': svc_name, 'error': msg}
)
return False
# 记录新大小
new_size = self._get_remote_file_size(target_ssh, dst_file)
target_result['service_details'].append({
'name': svc_name,
'type': '后端jar',
'path': mapped_path,
'old_size': self._format_size(old_size),
'new_size': self._format_size(new_size),
'status': '成功'
})
self.log(f" {svc_name} 更新成功")
# 步骤4.6-4.7:更新文件夹后端服务并恢复settingbus.conf
self.log("--- 更新文件夹后端服务 ---")
for svc in self.services['backend_folder']:
svc_name = svc['name']
mapped_path = self._get_mapped_path(svc['path'])
src_dir = f"{update_dir}/{mapped_path}"
dst_dir = f"{base_dir}/{mapped_path}"
config_relative = svc['config_file']
config_file = f"{dst_dir}/{config_relative}"
self.log(f"正在更新后端服务: {svc_name}")
# 记录原始大小
old_size = self._get_remote_file_size(target_ssh, dst_dir)
# 覆盖服务目录
exit_code, out, err = self._exec_remote_cmd(
target_ssh,
f'rm -rf "{dst_dir}" && cp -r "{src_dir}" "{dst_dir}"'
)
if exit_code != 0:
msg = f"覆盖后端服务 {svc_name} 失败: {err}"
self.log(msg, "ERROR")
target_result['errors'].append(
{'service': svc_name, 'error': msg}
)
return False
# 从备份恢复settingbus.conf
backup_config = (
f"{backup_dir}/{mapped_path}/{config_relative}"
)
exit_code, out, err = self._exec_remote_cmd(
target_ssh,
f'if [ -f "{backup_config}" ]; then '
f'mkdir -p "$(dirname "{config_file}")" && '
f'cp -f "{backup_config}" "{config_file}" && '
f'echo RESTORED; else echo NO_CONFIG; fi'
)
if "RESTORED" in out:
self.log(f" 已恢复 {config_relative}")
else:
self.log(
f" 无需恢复 {config_relative}(备份中不存在)",
"WARN"
)
# 记录新大小
new_size = self._get_remote_file_size(target_ssh, dst_dir)
target_result['service_details'].append({
'name': svc_name,
'type': '后端文件夹',
'path': mapped_path,
'old_size': self._format_size(old_size),
'new_size': self._format_size(new_size),
'status': '成功'
})
self.log(f" {svc_name} 更新成功")
# 步骤4.8:清理解压临时目录
self._exec_remote_cmd(target_ssh, f'rm -rf "{update_dir}"')
self.log("已清理临时解压目录")
updated_count = len(target_result['service_details'])
self.log(
f"阶段四:{target_name} 服务更新完成,"
f"共更新 {updated_count} 个服务 ✅"
)
return True
# ==================== 阶段五:容器重启与检测 ====================
def phase5_restart_containers(self, target):
"""阶段五:重启Docker容器并检测进程
Args:
target: 目标服务器配置字典
Returns:
bool: 是否成功
"""
target_name = target['name']
target_ssh = target['ssh']
target_result = self._get_target_result(target)
self.log("-" * 60)
self.log(f"阶段五:重启 {target_name} 容器并检测")
self.log("-" * 60)
# 步骤5.1:重启容器
containers_str = ' '.join(self.containers)
self.log(f"正在重启容器: {containers_str}...")
exit_code, out, err = self._exec_remote_cmd(
target_ssh,
f'docker restart {containers_str}',
timeout=300
)
if exit_code != 0:
self.log(f"容器重启失败: {err}", "ERROR")
target_result['errors'].append(
{'service': 'Docker', 'error': f"容器重启失败: {err}"}
)
return False
self.log("容器重启命令执行完成")
# 步骤5.2:等待容器启动
self.log("等待30秒让容器初始化...")
time.sleep(30)
# 步骤5.3-5.5:进程检测
all_ok = True
container_results = []
for container in self.containers:
self.log(f"检测容器 {container} 进程...")
exit_code, out, err = self._exec_remote_cmd(
target_ssh,
f'docker exec {container} ps -ef 2>&1',
timeout=30
)
if exit_code != 0 or not out.strip():
self.log(
f" 容器 {container} 进程检测失败", "ERROR"
)
container_results.append({
'name': container,
'status': '失败',
'detail': err.strip() if err.strip() else '无输出'
})
all_ok = False
else:
# 统计进程数量(排除ps命令本身和表头)
process_lines = [
line for line in out.strip().split('\n')
if line.strip() and 'ps -ef' not in line
and 'UID' not in line
]
process_count = len(process_lines)
self.log(
f" 容器 {container} 运行正常,"
f"进程数: {process_count} ✅"
)
container_results.append({
'name': container,
'status': '成功',
'detail': f'进程数: {process_count}'
})
target_result['container_results'] = container_results
if all_ok:
self.log(f"阶段五:{target_name} 容器重启检测全部通过 ✅")
else:
self.log(
f"阶段五:{target_name} 部分容器检测失败,"
f"请检查容器日志", "WARN"
)
return all_ok
# ==================== 阶段六:清理与报告 ====================
def phase6_cleanup_and_report(self, target):
"""阶段六:清理临时文件
Args:
target: 目标服务器配置字典
"""
target_name = target['name']
target_ssh = target['ssh']
self.log("-" * 60)
self.log(f"阶段六:清理 {target_name} 临时文件")
self.log("-" * 60)
# 清理目标服务器上的压缩包
self.log("清理目标服务器临时文件...")
self._exec_remote_cmd(
target_ssh, 'rm -f /data/services_update.tar.gz'
)
self._exec_remote_cmd(
target_ssh, 'rm -rf /data/services_update'
)
self.log("目标服务器临时文件已清理")
# 断开目标服务器连接
if target.get('sftp'):
try:
target['sftp'].close()
except Exception:
pass
if target.get('ssh'):
try:
target['ssh'].close()
except Exception:
pass
self.log(f"已断开 {target_name} 连接")
def _get_target_result(self, target):
"""获取或创建目标服务器的结果记录
Args:
target: 目标服务器配置字典
Returns:
dict: 目标服务器结果字典
"""
host = target['host']
for result in self.report_data['target_results']:
if result['host'] == host:
return result
# 创建新的结果记录
result = {
'name': target['name'],
'host': host,
'status': '成功',
'backup_dir': '',
'backup_time': '',
'package_size': '',
'service_details': [],
'container_results': [],
'errors': []
}
self.report_data['target_results'].append(result)
return result
# ==================== 报告生成 ====================
def generate_report(self):
"""生成更新报告,存储到本地reports目录
Returns:
str: 报告文件路径
"""
self.log("=" * 60)
self.log("生成更新报告")
self.log("=" * 60)
# 计算总耗时
end_time = datetime.now()
self.report_data['end_time'] = (
end_time.strftime('%Y-%m-%d %H:%M:%S')
)
if self.total_start_time:
duration = end_time - self.total_start_time
total_seconds = int(duration.total_seconds())
minutes, seconds = divmod(total_seconds, 60)
self.report_data['total_duration'] = f"{minutes}分{seconds}秒"
else:
self.report_data['total_duration'] = "未知"
# 判断整体状态
has_error = False
for result in self.report_data['target_results']:
if result['errors']:
result['status'] = '失败'
has_error = True
elif any(
c['status'] == '失败'
for c in result.get('container_results', [])
):
result['status'] = '部分成功'
has_error = True
else:
result['status'] = '成功'
if has_error:
self.report_data['status'] = '部分失败'
else:
self.report_data['status'] = '成功'
# 构建报告内容
report_content = self._build_report()
# 保存报告
date_str = datetime.now().strftime('%Y%m%d')
# 如果只有一个目标服务器,IP放在文件名中
if len(self.report_data['target_results']) == 1:
host = self.report_data['target_results'][0]['host']
report_name = (
f'X86_{host}_环境版本更新报告_{date_str}.md'
)
else:
report_name = f'X86_环境版本更新报告_{date_str}.md'
report_path = os.path.join(self.reports_dir, report_name)
with open(report_path, 'w', encoding='utf-8') as f:
f.write(report_content)
self.log(f"报告已生成: {report_path}")
return report_path
def _build_report(self):
"""构建报告Markdown内容
Returns:
str: 报告Markdown文本
"""
lines = []
# 标题和基本信息
lines.append("# X86自动更新环境版本报告\n")
lines.append("## 基本信息\n")
lines.append(
f"- 执行时间:{self.report_data['start_time']}"
)
lines.append(
f"- 结束时间:{self.report_data['end_time']}"
)
lines.append(
f"- 总耗时:{self.report_data['total_duration']}"
)
status = self.report_data['status']
status_icon = "✅" if status == "成功" else "⚠️"
lines.append(f"- 执行状态:**{status_icon} {status}**\n")
# 测试服务器信息
lines.append("## 服务器信息\n")
lines.append(
f"- 测试服务器(服务来源):{self.test_server['host']}"
)
lines.append("")
lines.append("| 目标服务器 | 更新状态 |")
lines.append("|-----------|---------|")
for result in self.report_data['target_results']:
r_status = result['status']
r_icon = "✅" if r_status == "成功" else "❌"
lines.append(
f"| {result['name']} ({result['host']}) "
f"| {r_icon} {r_status} |"
)
lines.append("")
# 备份信息
lines.append("## 备份信息\n")
for result in self.report_data['target_results']:
lines.append(
f"- **{result['name']} ({result['host']})**"
)
lines.append(
f" - 备份目录:`{result.get('backup_dir', '未备份')}`"
)
lines.append(
f" - 备份时间:{result.get('backup_time', 'N/A')}"
)
lines.append("")
# 服务更新详情
for result in self.report_data['target_results']:
lines.append(
f"## 服务更新详情 - {result['name']} "
f"({result['host']})\n"
)
if result.get('package_size'):
lines.append(
f"- 服务包大小:{result['package_size']}\n"
)
if result['service_details']:
lines.append(
"| 服务名称 | 类型 | 更新前大小 | "
"更新后大小 | 状态 |"
)
lines.append(
"|---------|------|-----------|"
"-----------|------|"
)
for svc in result['service_details']:
s_icon = "✅" if svc['status'] == "成功" else "❌"
lines.append(
f"| {svc['name']} | {svc['type']} "
f"| {svc['old_size']} | {svc['new_size']} "
f"| {s_icon} {svc['status']} |"
)
lines.append("")
# 容器重启结果
if result.get('container_results'):
lines.append("**容器重启结果**:\n")
lines.append("| 容器名称 | 状态 | 详情 |")
lines.append("|---------|------|------|")
for c in result['container_results']:
c_icon = "✅" if c['status'] == "成功" else "❌"
lines.append(
f"| {c['name']} "
f"| {c_icon} {c['status']} "
f"| {c['detail']} |"
)
lines.append("")
# 异常记录
has_errors = any(
r['errors'] for r in self.report_data['target_results']
)
if has_errors:
lines.append("## 异常记录\n")
for result in self.report_data['target_results']:
for err in result['errors']:
lines.append(
f"- **{result['name']} - {err['service']}**: "
f"{err['error']}"
)
lines.append("")
return '\n'.join(lines)
# ==================== 主执行流程 ====================
def run(self, check_only=False, skip_restart=False):
"""执行完整的环境版本更新流程
Args:
check_only: 仅执行前置检查,不实际更新
skip_restart: 跳过容器重启步骤
Returns:
bool: 是否全部成功
"""
self.total_start_time = datetime.now()
self.report_data['start_time'] = (
self.total_start_time.strftime('%Y-%m-%d %H:%M:%S')
)
# 初始化日志
self._init_log()
self.log("X86自动更新环境版本 - 开始执行")
mode = '仅前置检查' if check_only else (
'完整更新(跳过重启)' if skip_restart else '完整更新'
)
self.log(f"模式: {mode}")
try:
# 阶段一:前置检查
if not self.phase1_pre_check():
self.log("前置检查失败,终止执行", "ERROR")
self.generate_report()
return False
if check_only:
self.log("前置检查模式,跳过后续阶段")
self._disconnect_all()
self.generate_report()
return True
all_success = True
# 逐台处理目标服务器
for target in self.target_servers:
target_name = target['name']
target_host = target['host']
self.log("=" * 60)
self.log(
f"开始处理目标服务器: "
f"{target_name} ({target_host})"
)
self.log("=" * 60)
# 初始化结果记录
self._get_target_result(target)
try:
# 阶段二:打包与传输
if not self.phase2_package_and_transfer(target):
self.log(
f"{target_name} 打包传输失败", "ERROR"
)
all_success = False
continue
# 阶段三:备份
if not self.phase3_backup_services(target):
self.log(
f"{target_name} 备份失败", "ERROR"
)
all_success = False
continue
# 阶段四:更新与配置恢复
if not self.phase4_update_and_restore(target):
self.log(
f"{target_name} 服务更新失败", "ERROR"
)
all_success = False
continue
# 阶段五:容器重启与检测
if not skip_restart:
if not self.phase5_restart_containers(target):
self.log(
f"{target_name} 容器重启检测失败",
"WARN"
)
# 容器检测失败不阻断后续操作
else:
self.log(
f"跳过 {target_name} 容器重启(--skip-restart)"
)
# 阶段六:清理
self.phase6_cleanup_and_report(target)
self.log(
f"目标服务器 {target_name} ({target_host}) "
f"处理完成 ✅"
)
except Exception as e:
msg = (
f"{target_name} ({target_host}) "
f"处理异常: {e}"
)
self.log(msg, "ERROR")
target_result = self._get_target_result(target)
target_result['errors'].append(
{'service': '系统', 'error': str(e)}
)
all_success = False
# 尝试清理
try:
self.phase6_cleanup_and_report(target)
except Exception:
pass
# 断开测试服务器连接
self._disconnect_all()
# 生成最终报告
report_path = self.generate_report()
self.log("=" * 60)
self.log(
f"全部完成!总耗时: "
f"{self.report_data['total_duration']}"
)
self.log(f"报告路径: {report_path}")
self.log("=" * 60)
return all_success
except KeyboardInterrupt:
self.log("\n用户中断执行", "WARN")
self.report_data['status'] = '中断'
self.generate_report()
return False
except Exception as e:
self.log(f"执行异常: {e}", "ERROR")
self.report_data['status'] = '异常'
self.report_data['errors'].append(str(e))
self.generate_report()
return False
finally:
# 确保断开所有连接
self._disconnect_all()
# 清理本地临时文件
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def main():
"""主入口函数"""
parser = argparse.ArgumentParser(
description='X86自动更新环境版本工具'
)
parser.add_argument(
'--config',
default=None,
help='指定配置文件路径(默认: update_config.json)'
)
parser.add_argument(
'--check-only',
action='store_true',
help='仅执行前置检查(不实际更新)'
)
parser.add_argument(
'--skip-restart',
action='store_true',
help='跳过容器重启步骤'
)
args = parser.parse_args()
updater = X86EnvUpdate(config_path=args.config)
success = updater.run(
check_only=args.check_only,
skip_restart=args.skip_restart
)
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()
......@@ -2308,24 +2308,8 @@ function Main {
}
Write-Host ""
# 选择检测模式
Write-Host "==================================================================" -ForegroundColor Cyan
Write-Host " 请选择检测模式" -ForegroundColor Cyan
Write-Host "==================================================================" -ForegroundColor Cyan
Write-Host " [1] PowerShell模块模式 (默认,兼容性最好)"
Write-Host " [2] Shell脚本模式 (Linux服务器本地执行,性能更好)"
Write-Host "==================================================================" -ForegroundColor Cyan
$modeChoice = Read-Host "请输入模式编号 [默认: 1]"
if ($modeChoice -eq "2") {
$global:UseShellMode = $true
Write-Host "[模式] 已选择:Shell脚本模式" -ForegroundColor Green
}
else {
$global:UseShellMode = $false
Write-Host "[模式] 已选择:PowerShell模块模式" -ForegroundColor Green
}
Write-Host ""
# 检测模式:默认使用PowerShell模块模式(Shell脚本模式功能不完整,暂时屏蔽选择)
$global:UseShellMode = $false
# 检测平台类型
$platformType = Get-PlatformType -Server $server
......
......@@ -2843,6 +2843,572 @@ export_logs() {
echo "$export_dir"
}
# ------------------------------
# 18.5) 配置文件与日志导出(对齐 ps1 LogExport.psm1)
# 功能:将各服务的配置文件和日志文件按服务名称分类收集到脚本所在目录的 config_and_log/ 下,
# 压缩为 tar.gz 后保存在同一目录。
# 说明:Shell 版本直接在本机运行,无需 SSH/下载,所有文件操作都在脚本所在目录完成。
# ------------------------------
export_config_and_logs() {
local platform="$1"
log INFO "========== 配置文件与日志导出 =========="
local tmp_base="$SCRIPT_DIR/config_and_log"
local server_ip
server_ip="$(get_primary_ip)"
local archive_name="${server_ip}_${TS}.tar.gz"
# 统计结果
local exported_svcs=()
local skipped_svcs=()
local failed_svcs=()
# ---------- Step 1: 创建临时目录 ----------
log INFO "[Step 1] 创建临时目录: $tmp_base"
rm -rf "$tmp_base"
mkdir -p "$tmp_base" || {
log ERROR "[Step 1] 创建临时目录失败"
report_kv_set "cfglog.status" "失败"
report_kv_set "cfglog.error" "创建临时目录失败"
return 1
}
log SUCCESS "[Step 1] 临时目录已创建"
# ---------- 内部辅助函数:复制单个文件/目录到临时目录 ----------
# 参数: $1=svcBase $2=相对路径 $3=目标目录($tmp_base/$svcName)
# 支持: 简单路径(文件/目录)、通配符路径(含*)
_copy_item() {
local svc_base="$1"
local rel_path="$2"
local dst_dir="$3"
local src_path="${svc_base}/${rel_path}"
# 通配符路径
if [[ "$rel_path" == *'*'* ]]; then
# shellcheck disable=SC2086
cp -r ${src_path} "${dst_dir}/" 2>/dev/null
return $?
fi
# 目录
if [[ -d "$src_path" ]]; then
cp -r "$src_path" "${dst_dir}/" 2>/dev/null
return $?
fi
# 文件
if [[ -f "$src_path" ]]; then
mkdir -p "$(dirname "${dst_dir}/${rel_path}")" 2>/dev/null
cp -f "$src_path" "${dst_dir}/${rel_path}" 2>/dev/null
return $?
fi
# 不存在则静默跳过
return 0
}
# 按扩展名过滤复制(对齐 ps1 ext-filter 类型)
# 参数: $1=svcBase $2=相对目录路径 $3=目标目录 $4=扩展名列表(空格分隔,如 "*.yml *.properties")
_copy_ext_filter() {
local svc_base="$1"
local rel_path="$2"
local dst_dir="$3"
shift 3
local exts=("$@")
local src_path="${svc_base}/${rel_path}"
[[ -d "$src_path" ]] || return 0
mkdir -p "${dst_dir}/${rel_path}" 2>/dev/null
# 构建 find -name 参数
local find_args=()
local first=1
for ext in "${exts[@]}"; do
if [[ $first -eq 1 ]]; then
find_args+=(-name "$ext")
first=0
else
find_args+=(-o -name "$ext")
fi
done
# 从 svc_base 目录执行 find,保持相对路径结构
local count=0
pushd "$svc_base" >/dev/null 2>&1 || return 0
while IFS= read -r -d '' f; do
mkdir -p "$(dirname "${dst_dir}/${f}")" 2>/dev/null
cp -f "$f" "${dst_dir}/${f}" 2>/dev/null
((count++))
done < <(find "$rel_path" -type f \( "${find_args[@]}" \) -print0 2>/dev/null)
popd >/dev/null 2>&1 || true
return 0
}
# 按修改时间过滤复制(对齐 ps1 mtime 类型)
# 参数: $1=svcBase $2=相对目录路径 $3=目标目录 $4=天数
_copy_mtime() {
local svc_base="$1"
local rel_path="$2"
local dst_dir="$3"
local days="$4"
local src_path="${svc_base}/${rel_path}"
[[ -d "$src_path" ]] || return 0
mkdir -p "${dst_dir}/${rel_path}" 2>/dev/null
find "$src_path" -type f -mtime -"${days}" -exec cp -f {} "${dst_dir}/${rel_path}/" \; 2>/dev/null
return 0
}
# ---------- Step 2: 定义服务列表并遍历复制 ----------
# --- 新统一平台服务定义 ---
# 格式: "服务名|基础目录" 后跟该服务的文件列表
local new_platform_services=()
# 前端服务
local fe_svcs=(
"pc-vue2-ai|/data/services/web/pc/pc-vue2-ai|static/config.json"
"pc-vue2-backstage|/data/services/web/pc/pc-vue2-backstage|static/config.json"
"pc-vue2-main|/data/services/web/pc/pc-vue2-main|static/config.json"
"pc-vue2-meetngV2|/data/services/web/pc/pc-vue2-meetngV2|static/config.json"
"pc-vue2-meetngV3|/data/services/web/pc/pc-vue2-meetngV3|static/config.json"
"pc-vue2-meetingControl|/data/services/web/pc/pc-vue2-meetingControl|static/config.json"
"pc-vue2-moniter|/data/services/web/pc/pc-vue2-moniter|static/config.json"
"pc-vue2-platform|/data/services/web/pc/pc-vue2-platform|static/config.json"
"pc-vue2-voice|/data/services/web/pc/pc-vue2-voice/pc-vue2-voice|static/config.json"
"h5-uniapp-meeting|/data/services/web/h5/h5-uniapp-meeting|static/config.json"
"h5-uniapp-moniter|/data/services/web/h5/h5-uniapp-moniter|static/config.json"
"h5-platform-mobile|/data/services/web/h5/h5-uniapp-platform/meeting-mobile|static/config.json"
"h5-platform-platform-mobile|/data/services/web/h5/h5-uniapp-platform/unified-platform-mobile|static/config.json"
)
# 后端服务
local be_svcs=(
"auth-sso-auth|/data/services/api/auth/auth-sso-auth|log.out|config"
"auth-sso-gatway|/data/services/api/auth/auth-sso-gatway|log.out|config"
"auth-sso-system|/data/services/api/auth/auth-sso-system|log.out|config"
)
# Java 后端服务(带 ext-filter 和 mtime 类型文件)
local java_be_svcs=(
"java-meeting2.0|/data/services/api/java-meeting/java-meeting2.0"
"java-meeting-extapi|/data/services/api/java-meeting/java-meeting-extapi"
"java-message-scheduling|/data/services/api/java-meeting/java-message-scheduling"
"java-mqtt|/data/services/api/java-meeting/java-mqtt"
"java-quartz|/data/services/api/java-meeting/java-quartz"
)
# Python 后端服务
local py_be_svcs=(
"python-cmdb|/data/services/api/python-cmdb"
"python-voice|/data/services/api/python-voice"
)
# 中间件服务
local mw_svcs=(
"nginx|/data/middleware/nginx|log|config"
"redis|/data/middleware/redis|config|log"
"emqx|/data/middleware/emqx|config|log"
"mysql|/data/middleware/mysql|log|conf"
)
# Nacos 中间件(特殊,有大量独立日志文件)
local nacos_base="/data/middleware/nacos"
local nacos_log_files=(
"logs/alipay-jraft.log" "logs/cmdb-main.log" "logs/config-dump.log"
"logs/config-fatal.log" "logs/config-memory.log" "logs/config-notify.log"
"logs/config-pull.log" "logs/core-auth.log" "logs/istio-main.log"
"logs/nacos.log" "logs/nacos-persistence.log" "logs/naming-distro.log"
"logs/naming-event.log" "logs/naming-performance.log" "logs/naming-push.log"
"logs/naming-raft.log" "logs/naming-rt.log" "logs/naming-server.log"
"logs/plugin-control.log" "logs/plugin-control-tps.log"
"logs/protocol-distro.log" "logs/protocol-raft.log"
"logs/remote-digest.log" "logs/remote.log" "logs/remote-push.log"
)
# 第三方服务
local tp_svcs=(
"paperless|/data/third_party/paperless|application.yml|nohup.out"
"wifi-local|/data/third_party/wifi-local|nohup.out|config.ini"
)
# --- 传统平台服务定义 ---
local old_fe_svcs=(
"ubains-web-2.0|/var/www/java/ubains-web-2.0|static/config.json"
"ubains-web-admin|/var/www/java/ubains-web-admin|static/config.json"
"ubains-video-web-3.0|/var/www/java/ubains-video-web-3.0|static/config.json"
"ubains-web-h5|/var/www/java/ubains-web-h5|static/h5/config.json"
"web-vue-rms|/var/www/html/web-vue-rms|static/config.json"
"web-vue-h5|/var/www/html/web-vue-h5|static/config.json"
)
# 传统平台 Java 后端(带 ext-filter 和 mtime)
local old_java_be_svcs=(
"api-java-meeting2.0|/var/www/java/api-java-meeting2.0"
"external-meeting-api|/var/www/java/external-meeting-api"
)
local old_be_svcs=(
"html-ops|/var/www/html|setting.conf|log/uinfo.log|log/error.log|log/uwsgi.log"
)
local old_mw_svcs=(
"nginx|/var/www/java/nginx-conf.d|meeting443.conf|nginx_log"
"redis|/var/www/redis|*.conf|data"
"emqx|/var/www/emqx|config|log"
"mysql|/usr/local/docker/mysql|logs"
"fdfs|/var/fdfs|storage/logs|tracker/logs"
)
local old_tp_svcs=(
"paperless|/var/www/paperless|application.yml|nohup.out"
"wifi-local|/var/www/wifi-local|config.ini|nohup.out"
)
# ---------- Step 2: 遍历服务 ----------
log INFO "[Step 2] 开始扫描服务..."
if [[ "$platform" == "new" ]]; then
log INFO "平台类型: 新统一平台"
# --- 前端服务 ---
for entry in "${fe_svcs[@]}"; do
IFS='|' read -r svc_name svc_base rel_file <<< "$entry"
if [[ ! -d "$svc_base" ]]; then
log WARN " [SKIP] $svc_name - 目录不存在: $svc_base"
skipped_svcs+=("$svc_name|目录不存在: $svc_base")
continue
fi
mkdir -p "${tmp_base}/${svc_name}"
_copy_item "$svc_base" "$rel_file" "${tmp_base}/${svc_name}" && \
exported_svcs+=("$svc_name|$rel_file") || \
failed_svcs+=("$svc_name|$rel_file")
done
# --- 后端服务(简单文件列表)---
for entry in "${be_svcs[@]}"; do
IFS='|' read -r svc_name svc_base files_str <<< "$entry"
if [[ ! -d "$svc_base" ]]; then
log WARN " [SKIP] $svc_name - 目录不存在: $svc_base"
skipped_svcs+=("$svc_name|目录不存在: $svc_base")
continue
fi
mkdir -p "${tmp_base}/${svc_name}"
local copied=0
# files_str 可能包含多个文件,用 | 分隔(第一个|之后的部分)
local remaining="${entry#*|*}" # 去掉 name|base 后的部分
# 重新解析:name|base|file1|file2|...
IFS='|' read -ra parts <<< "$entry"
local svc_base2="${parts[1]}"
local j
for ((j=2; j<${#parts[@]}; j++)); do
_copy_item "$svc_base2" "${parts[$j]}" "${tmp_base}/${svc_name}" && ((copied++)) || true
done
if [[ $copied -gt 0 ]]; then
exported_svcs+=("$svc_name|${copied}个文件")
else
failed_svcs+=("$svc_name|目录存在但无文件导出")
fi
done
# --- Java 后端服务(带 ext-filter 和 mtime)---
for entry in "${java_be_svcs[@]}"; do
IFS='|' read -r svc_name svc_base <<< "$entry"
if [[ ! -d "$svc_base" ]]; then
log WARN " [SKIP] $svc_name - 目录不存在: $svc_base"
skipped_svcs+=("$svc_name|目录不存在: $svc_base")
continue
fi
mkdir -p "${tmp_base}/${svc_name}"
local copied=0
# logs/ubains-INFO-AND-ERROR.log
_copy_item "$svc_base" "logs/ubains-INFO-AND-ERROR.log" "${tmp_base}/${svc_name}" && ((copied++)) || true
# logs/ubains-ERROR.log
_copy_item "$svc_base" "logs/ubains-ERROR.log" "${tmp_base}/${svc_name}" && ((copied++)) || true
# mtime: 最近3天的日志文件
_copy_mtime "$svc_base" "logs" "${tmp_base}/${svc_name}" 3 && ((copied++)) || true
# ext-filter: config 目录下的 .yml .properties .json .js .xml
_copy_ext_filter "$svc_base" "config" "${tmp_base}/${svc_name}" "*.yml" "*.properties" "*.json" "*.js" "*.xml" && ((copied++)) || true
if [[ $copied -gt 0 ]]; then
log SUCCESS " [OK] $svc_name - 已导出"
exported_svcs+=("$svc_name|${copied}项")
else
log WARN " [FAIL] $svc_name - 无文件导出"
failed_svcs+=("$svc_name|目录存在但无文件导出")
fi
done
# --- Python 后端服务 ---
for entry in "${py_be_svcs[@]}"; do
IFS='|' read -r svc_name svc_base <<< "$entry"
if [[ ! -d "$svc_base" ]]; then
log WARN " [SKIP] $svc_name - 目录不存在: $svc_base"
skipped_svcs+=("$svc_name|目录不存在: $svc_base")
continue
fi
mkdir -p "${tmp_base}/${svc_name}"
local copied=0
if [[ "$svc_name" == "python-cmdb" ]]; then
_copy_ext_filter "$svc_base" "log" "${tmp_base}/${svc_name}" "*.log" "*.log.1" "*.log.2" "*.log.3" && ((copied++)) || true
_copy_item "$svc_base" "setting.conf" "${tmp_base}/${svc_name}" && ((copied++)) || true
_copy_item "$svc_base" "cmdb/bus/config/settingbus.conf" "${tmp_base}/${svc_name}" && ((copied++)) || true
elif [[ "$svc_name" == "python-voice" ]]; then
_copy_ext_filter "$svc_base" "log" "${tmp_base}/${svc_name}" "*.log" "*.log.1" "*.log.2" "*.log.3" && ((copied++)) || true
_copy_item "$svc_base" "setting.conf" "${tmp_base}/${svc_name}" && ((copied++)) || true
_copy_item "$svc_base" "uvoice/bus/config/settingbus.conf" "${tmp_base}/${svc_name}" && ((copied++)) || true
fi
if [[ $copied -gt 0 ]]; then
log SUCCESS " [OK] $svc_name - 已导出"
exported_svcs+=("$svc_name|${copied}项")
else
log WARN " [FAIL] $svc_name - 无文件导出"
failed_svcs+=("$svc_name|目录存在但无文件导出")
fi
done
# --- 中间件服务 ---
for entry in "${mw_svcs[@]}"; do
IFS='|' read -ra parts <<< "$entry"
local svc_name="${parts[0]}"
local svc_base="${parts[1]}"
if [[ ! -d "$svc_base" ]]; then
log WARN " [SKIP] $svc_name - 目录不存在: $svc_base"
skipped_svcs+=("$svc_name|目录不存在: $svc_base")
continue
fi
mkdir -p "${tmp_base}/${svc_name}"
local copied=0
for ((j=2; j<${#parts[@]}; j++)); do
_copy_item "$svc_base" "${parts[$j]}" "${tmp_base}/${svc_name}" && ((copied++)) || true
done
if [[ $copied -gt 0 ]]; then
log SUCCESS " [OK] $svc_name - 已导出"
exported_svcs+=("$svc_name|${copied}项")
else
failed_svcs+=("$svc_name|目录存在但无文件导出")
fi
done
# --- Nacos 中间件(特殊处理)---
if [[ ! -d "$nacos_base" ]]; then
log WARN " [SKIP] nacos - 目录不存在: $nacos_base"
skipped_svcs+=("nacos|目录不存在: $nacos_base")
else
mkdir -p "${tmp_base}/nacos"
local copied=0
_copy_item "$nacos_base" "conf" "${tmp_base}/nacos" && ((copied++)) || true
for nf in "${nacos_log_files[@]}"; do
_copy_item "$nacos_base" "$nf" "${tmp_base}/nacos" && ((copied++)) || true
done
if [[ $copied -gt 0 ]]; then
log SUCCESS " [OK] nacos - 已导出"
exported_svcs+=("nacos|${copied}项")
else
failed_svcs+=("nacos|目录存在但无文件导出")
fi
fi
# --- 第三方服务 ---
for entry in "${tp_svcs[@]}"; do
IFS='|' read -ra parts <<< "$entry"
local svc_name="${parts[0]}"
local svc_base="${parts[1]}"
if [[ ! -d "$svc_base" ]]; then
log WARN " [SKIP] $svc_name - 目录不存在: $svc_base"
skipped_svcs+=("$svc_name|目录不存在: $svc_base")
continue
fi
mkdir -p "${tmp_base}/${svc_name}"
local copied=0
for ((j=2; j<${#parts[@]}; j++)); do
_copy_item "$svc_base" "${parts[$j]}" "${tmp_base}/${svc_name}" && ((copied++)) || true
done
if [[ $copied -gt 0 ]]; then
log SUCCESS " [OK] $svc_name - 已导出"
exported_svcs+=("$svc_name|${copied}项")
else
failed_svcs+=("$svc_name|目录存在但无文件导出")
fi
done
else
# ===== 传统平台 =====
log INFO "平台类型: 传统平台"
# --- 前端服务 ---
for entry in "${old_fe_svcs[@]}"; do
IFS='|' read -r svc_name svc_base rel_file <<< "$entry"
if [[ ! -d "$svc_base" ]]; then
log WARN " [SKIP] $svc_name - 目录不存在: $svc_base"
skipped_svcs+=("$svc_name|目录不存在: $svc_base")
continue
fi
mkdir -p "${tmp_base}/${svc_name}"
_copy_item "$svc_base" "$rel_file" "${tmp_base}/${svc_name}" && \
exported_svcs+=("$svc_name|$rel_file") || \
failed_svcs+=("$svc_name|$rel_file")
done
# --- Java 后端服务(带 ext-filter 和 mtime)---
for entry in "${old_java_be_svcs[@]}"; do
IFS='|' read -r svc_name svc_base <<< "$entry"
if [[ ! -d "$svc_base" ]]; then
log WARN " [SKIP] $svc_name - 目录不存在: $svc_base"
skipped_svcs+=("$svc_name|目录不存在: $svc_base")
continue
fi
mkdir -p "${tmp_base}/${svc_name}"
local copied=0
_copy_item "$svc_base" "logs/ubains-INFO-AND-ERROR.log" "${tmp_base}/${svc_name}" && ((copied++)) || true
_copy_item "$svc_base" "logs/ubains-ERROR.log" "${tmp_base}/${svc_name}" && ((copied++)) || true
_copy_mtime "$svc_base" "logs" "${tmp_base}/${svc_name}" 3 && ((copied++)) || true
_copy_ext_filter "$svc_base" "config" "${tmp_base}/${svc_name}" "*.yml" "*.properties" "*.json" "*.js" "*.xml" && ((copied++)) || true
if [[ $copied -gt 0 ]]; then
log SUCCESS " [OK] $svc_name - 已导出"
exported_svcs+=("$svc_name|${copied}项")
else
log WARN " [FAIL] $svc_name - 无文件导出"
failed_svcs+=("$svc_name|目录存在但无文件导出")
fi
done
# --- 传统平台后端服务(简单文件列表)---
for entry in "${old_be_svcs[@]}"; do
IFS='|' read -ra parts <<< "$entry"
local svc_name="${parts[0]}"
local svc_base="${parts[1]}"
if [[ ! -d "$svc_base" ]]; then
log WARN " [SKIP] $svc_name - 目录不存在: $svc_base"
skipped_svcs+=("$svc_name|目录不存在: $svc_base")
continue
fi
mkdir -p "${tmp_base}/${svc_name}"
local copied=0
for ((j=2; j<${#parts[@]}; j++)); do
_copy_item "$svc_base" "${parts[$j]}" "${tmp_base}/${svc_name}" && ((copied++)) || true
done
if [[ $copied -gt 0 ]]; then
log SUCCESS " [OK] $svc_name - 已导出"
exported_svcs+=("$svc_name|${copied}项")
else
failed_svcs+=("$svc_name|目录存在但无文件导出")
fi
done
# --- 中间件服务 ---
for entry in "${old_mw_svcs[@]}"; do
IFS='|' read -ra parts <<< "$entry"
local svc_name="${parts[0]}"
local svc_base="${parts[1]}"
if [[ ! -d "$svc_base" ]]; then
log WARN " [SKIP] $svc_name - 目录不存在: $svc_base"
skipped_svcs+=("$svc_name|目录不存在: $svc_base")
continue
fi
mkdir -p "${tmp_base}/${svc_name}"
local copied=0
for ((j=2; j<${#parts[@]}; j++)); do
_copy_item "$svc_base" "${parts[$j]}" "${tmp_base}/${svc_name}" && ((copied++)) || true
done
if [[ $copied -gt 0 ]]; then
log SUCCESS " [OK] $svc_name - 已导出"
exported_svcs+=("$svc_name|${copied}项")
else
failed_svcs+=("$svc_name|目录存在但无文件导出")
fi
done
# --- 第三方服务 ---
for entry in "${old_tp_svcs[@]}"; do
IFS='|' read -ra parts <<< "$entry"
local svc_name="${parts[0]}"
local svc_base="${parts[1]}"
if [[ ! -d "$svc_base" ]]; then
log WARN " [SKIP] $svc_name - 目录不存在: $svc_base"
skipped_svcs+=("$svc_name|目录不存在: $svc_base")
continue
fi
mkdir -p "${tmp_base}/${svc_name}"
local copied=0
for ((j=2; j<${#parts[@]}; j++)); do
_copy_item "$svc_base" "${parts[$j]}" "${tmp_base}/${svc_name}" && ((copied++)) || true
done
if [[ $copied -gt 0 ]]; then
log SUCCESS " [OK] $svc_name - 已导出"
exported_svcs+=("$svc_name|${copied}项")
else
failed_svcs+=("$svc_name|目录存在但无文件导出")
fi
done
fi
log INFO "扫描完成: 成功=${#exported_svcs[@]}, 跳过=${#skipped_svcs[@]}, 失败=${#failed_svcs[@]}"
# ---------- Step 3: 压缩 ----------
# 在脚本所在目录下压缩 config_and_log 目录
local archive_path="${SCRIPT_DIR}/${archive_name}"
log INFO "[Step 3] 压缩: $archive_path"
tar -czf "$archive_path" -C "$SCRIPT_DIR" config_and_log 2>/dev/null
if [[ $? -ne 0 ]]; then
log ERROR "[Step 3] 压缩失败"
rm -rf "$tmp_base"
report_kv_set "cfglog.status" "失败"
report_kv_set "cfglog.error" "压缩失败"
return 1
fi
log SUCCESS "[Step 3] 压缩完成"
# ---------- Step 4: 清理临时目录中的文件(保留 tar.gz)----------
rm -rf "$tmp_base"
# 获取文件大小
local archive_size
archive_size="$(du -h "$archive_path" 2>/dev/null | cut -f1)"
log SUCCESS "[Step 4] 压缩包已保存: $archive_path (${archive_size})"
log SUCCESS "[Step 4] 临时目录已清理"
# ---------- 记录报告 KV ----------
report_kv_set "cfglog.status" "成功"
report_kv_set "cfglog.archive" "${SCRIPT_DIR}/${archive_name}"
report_kv_set "cfglog.exported_count" "${#exported_svcs[@]}"
report_kv_set "cfglog.skipped_count" "${#skipped_svcs[@]}"
report_kv_set "cfglog.failed_count" "${#failed_svcs[@]}"
# 保存详细列表(用特殊分隔符)
local exp_list=""
local item
for item in "${exported_svcs[@]}"; do
[[ -n "$exp_list" ]] && exp_list+="||"
exp_list+="$item"
done
report_kv_set "cfglog.exported_list" "$exp_list"
local skip_list=""
for item in "${skipped_svcs[@]}"; do
[[ -n "$skip_list" ]] && skip_list+="||"
skip_list+="$item"
done
report_kv_set "cfglog.skipped_list" "$skip_list"
local fail_list=""
for item in "${failed_svcs[@]}"; do
[[ -n "$fail_list" ]] && fail_list+="||"
fail_list+="$item"
done
report_kv_set "cfglog.failed_list" "$fail_list"
log SUCCESS "========== 配置文件与日志导出完成 =========="
log INFO "压缩包: ${SCRIPT_DIR}/${archive_name}"
log INFO "目录: $SCRIPT_DIR"
}
# ------------------------------
# 19) 报告生成(Markdown)—增强:把 KV 也写入
# ------------------------------
......@@ -3448,19 +4014,72 @@ main() {
report_add "- 用户选择跳过"
fi
# 10) 日志导出
section "日志导出(可选)"
read -r -p "是否导出服务日志到本地目录? (y/n) [默认:n]: " ex
local export_dir=""
# 10) 配置文件与日志导出(对齐需求文档,替换旧的"日志导出"功能)
section "配置文件与日志导出(可选)"
read -r -p "是否导出配置文件与日志文件? (y/n) [默认:n]: " ex
if [[ "${ex:-n}" =~ ^[Yy]$ ]]; then
export_dir="$(export_logs "$platform" "$HAS_UJAVA" "$HAS_UPYTHON" "${UJAVA_CONTAINER:-}" "${UPYTHON_CONTAINER:-}" || true)"
export_config_and_logs "$platform"
report_add ""
report_add "## 日志导出"
report_add "- 导出目录: $export_dir"
report_add "## 配置文件与日志导出"
report_add "- 状态: $(report_kv_get "cfglog.status")"
local cfglog_archive
cfglog_archive="$(report_kv_get "cfglog.archive")"
[[ -n "$cfglog_archive" ]] && report_add "- 压缩包: $cfglog_archive"
local cfglog_exp_count
cfglog_exp_count="$(report_kv_get "cfglog.exported_count")"
[[ -n "$cfglog_exp_count" ]] && report_add "- 成功导出服务数: $cfglog_exp_count"
local cfglog_skip_count
cfglog_skip_count="$(report_kv_get "cfglog.skipped_count")"
[[ -n "$cfglog_skip_count" ]] && report_add "- 跳过服务数: $cfglog_skip_count"
local cfglog_fail_count
cfglog_fail_count="$(report_kv_get "cfglog.failed_count")"
[[ -n "$cfglog_fail_count" ]] && report_add "- 失败服务数: $cfglog_fail_count"
# 详细导出服务列表
local cfglog_exp_list
cfglog_exp_list="$(report_kv_get "cfglog.exported_list")"
if [[ -n "$cfglog_exp_list" ]]; then
report_add ""
report_add "### 已导出服务"
local IFS_old="$IFS"
IFS='||'
local exp_item
for exp_item in $cfglog_exp_list; do
report_add "- ${exp_item//|/ → }"
done
IFS="$IFS_old"
fi
# 跳过的服务列表
local cfglog_skip_list
cfglog_skip_list="$(report_kv_get "cfglog.skipped_list")"
if [[ -n "$cfglog_skip_list" ]]; then
report_add ""
report_add "### 跳过的服务"
local IFS_old="$IFS"
IFS='||'
local skip_item
for skip_item in $cfglog_skip_list; do
report_add "- ${skip_item//|/ → }"
done
IFS="$IFS_old"
fi
# 失败的服务列表
local cfglog_fail_list
cfglog_fail_list="$(report_kv_get "cfglog.failed_list")"
if [[ -n "$cfglog_fail_list" ]]; then
report_add ""
report_add "### 失败的服务"
local IFS_old="$IFS"
IFS='||'
local fail_item
for fail_item in $cfglog_fail_list; do
report_add "- ${fail_item//|/ → }"
done
IFS="$IFS_old"
fi
else
log INFO "跳过日志导出"
log INFO "跳过配置文件与日志导出"
report_add ""
report_add "## 日志导出"
report_add "## 配置文件与日志导出"
report_add "- 用户选择跳过"
fi
......
......@@ -64,7 +64,7 @@
- static文件夹
### 后端服务目录:
- auth包:/data/services/api/auth/auth-sso-aut
- auth包:/data/services/api/auth/auth-sso-auth
- ubains-auth.jar
- gatway包:/data/services/api/auth/auth-sso-gatway
- ubains-gateway.jar
......@@ -91,7 +91,7 @@
## 打包服务器的服务目录信息
### 会议预定
#### 前端服务目录:
- ai包:/data/arn_offline_auto_unifiedPlatform/data/services/web/pc/pc-vue2-ai
- ai包:/data/arm_offline_auto_unifiedPlatform/data/services/web/pc/pc-vue2-ai
- index.html
- static文件夹
- 后台包:/data/arm_offline_auto_unifiedPlatform/data/services/web/pc/pc-vue2-backstage
......@@ -185,7 +185,7 @@
- uvoice文件夹后端包更新说明:
- 需要将原文件夹下bus/config/settingbus.conf覆盖到更新的服务包原路径。
- 所有服务更新操作完毕后需将/data/目录下的offline_auto_unifiedPlatform文件夹打压缩成tar.gz格式,并增加md5格式校验。
- 将tar.gz格式文件与md5格式文件拷贝至网盘目录:[Z:\发布版本\03服务器部署\15新统一平台\X86部署包\全量版\版本更新-待验证]
- 将tar.gz格式文件与md5格式文件拷贝至网盘目录:[Z:\发布版本\03服务器部署\15新统一平台\ARM部署包\全量版\版本更新-待验证]
- 拷贝到网盘目录后将打包服务器上的tar.gz和md5格式文件清理。
## 核验材料
......@@ -196,3 +196,23 @@
- 记录服务包的大小
- 操作耗时
- 更新成功或失败的状态
## 代码架构
本需求对应的脚本和配置文件位于 `AuxiliaryTool/ScriptTool/RemoteUpdate/` 目录下,与其他需求共享该目录,请注意区分,避免互相修改。
```
AuxiliaryTool/ScriptTool/RemoteUpdate/
├── x86_package_update.py ← 本需求使用的脚本(ARM自动更新部署包版本)
│ 同时也供以下需求使用,修改时需确保兼容:
│ - X86自动更新部署包版本
│ - 远程自动化部署(X86架构部署阶段)
├── config.json ← 本需求使用的配置文件(ARM配置项)
├── update_config.json ← 其他需求使用(X86自动更新环境版本),请勿修改
├── x86_env_update.py ← 其他需求使用(X86自动更新环境版本),请勿修改
└── reports/ ← 共用报告输出目录
```
**注意事项:**
- `x86_package_update.py` 为多个需求共用脚本,修改时必须确保不破坏其他需求的正常运行。
- `x86_env_update.py``update_config.json` 为"X86自动更新环境版本"需求专用,本需求不应修改。
- 如需新增功能,优先通过 `config.json` 配置化实现,避免修改共用脚本核心逻辑。
......@@ -196,3 +196,23 @@
- 记录服务包的大小
- 操作耗时
- 更新成功或失败的状态
## 代码架构
本需求对应的脚本和配置文件位于 `AuxiliaryTool/ScriptTool/RemoteUpdate/` 目录下,与其他需求共享该目录,请注意区分,避免互相修改。
```
AuxiliaryTool/ScriptTool/RemoteUpdate/
├── x86_package_update.py ← 本需求使用的脚本(X86自动更新部署包版本)
│ 同时也供以下需求使用,修改时需确保兼容:
│ - ARM自动更新部署包版本
│ - 远程自动化部署(X86架构部署阶段)
├── config.json ← 本需求使用的配置文件
├── update_config.json ← 其他需求使用(X86自动更新环境版本),请勿修改
├── x86_env_update.py ← 其他需求使用(X86自动更新环境版本),请勿修改
└── reports/ ← 共用报告输出目录
```
**注意事项:**
- `x86_package_update.py` 为多个需求共用脚本,修改时必须确保不破坏其他需求的正常运行。
- `x86_env_update.py``update_config.json` 为"X86自动更新环境版本"需求专用,本需求不应修改。
- 如需新增功能,优先通过 `config.json` 配置化实现,避免修改共用脚本核心逻辑。
# 自动更新环境版本
## 脚本
- `AuxiliaryTool/ScriptTool/RemoteUpdate`
## 服务器信息
### 测试服务器
- IP:192.168.5.44
- 端口:22
- 账号:root
- 密码:Ubains@123
### 其他环境服务器
- IP:192.168.5.46
- 端口:22
- 账号:root
- 密码:Ubains@123
## 测试服务器的服务目录信息
### 会议预定
#### 前端服务目录:
- ai包:/data/services/web/pc/pc-vue2-ai
- index.html
- static文件夹
- 后台包:/data/services/web/pc/pc-vue2-backstage
- index.html
- static文件夹
- main包:/data/services/web/pc/pc-vue2-main
- index.html
- static文件夹
- meetngV2包:/data/services/web/pc/pc-vue2-meetngV2
- index.html
- static文件夹
- meetngV3包:/data/services/web/pc/pc-vue2-meetngV3
- index.html
- static文件夹
- meetingControl:/data/services/web/pc/pc-vue2-meetingControl
- index.html
- static文件夹
- monitor包:/data/services/web/pc/pc-vue2-moniter
- index.html
- static文件夹
- module文件夹
- platform包:/data/services/web/pc/pc-vue2-platform
- index.html
- static文件夹
- temp文件夹
- voice包:/data/services/web/pc/pc-vue2-voice/pc-vue2-voice
- index.html
- static文件夹
- h5-meeting:/data/services/web/h5/h5-uniapp-meeting
- index.html
- static文件夹
- h5-moniter:/data/services/web/h5/h5-uniapp-moniter
- index.html
- static文件夹
- h5-platform-mobile:/data/services/web/h5/h5-uniapp-platform/meeting-mobile
- assets文件夹
- index.html
- static文件夹
- h5-platform-platform-mobile:/data/services/web/h5/h5-uniapp-platform/unified-platform-mobile
- index.html
- static文件夹
### 后端服务目录:
- auth包:/data/services/api/auth/auth-sso-auth
- ubains-auth.jar
- gatway包:/data/services/api/auth/auth-sso-gatway
- ubains-gateway.jar
- system包:/data/services/api/auth/auth-sso-system
- ubains-modules-system.jar
- java2.0包:/data/services/api/java-meeting/java-meeting2.0
- ubains-meeting-inner-api-1.0-SNAPSHOT.jar
- java-extapi包:/data/services/api/java-meeting/java-meeting-extapi
- ubains-meeting-api-1.0-SNAPSHOT.jar
- java-scheduling包:/data/services/api/java-meeting/java-message-scheduling
- ubains-meeting-message-scheduling-1.0-SNAPSHOT.jar
- java-mqtt包:/data/services/api/java-meeting/java-mqtt
- ubains-meeting-mqtt-1.0-SNAPSHOT.jar
- java-quartz包:/data/services/api/java-meeting/java-quartz
- ubains-meeting-quartz-1.0-SNAPSHOT.jar
- cmdb包:/data/services/api/python-cmdb
- cmdb文件夹
- UbainsDevOps文件夹
- voice包:/data/services/api/python-voice
- UbainsDevOps文件夹
- uvoice文件夹
## 其他环境服务器的服务目录信息
### 会议预定
#### 前端服务目录:
- ai包:/data/services/web/pc/pc-vue2-ai
- index.html
- static文件夹
- 后台包:/data/services/web/pc/pc-vue2-backstage
- index.html
- static文件夹
- main包:/data/services/web/pc/pc-vue2-main
- index.html
- static文件夹
- meetngV2包:/data/services/web/pc/pc-vue2-meetingV2
- index.html
- static文件夹
- meetngV3包:/data/services/web/pc/pc-vue2-meetingV3
- index.html
- static文件夹
- meetingControl:/data/services/web/pc/pc-vue2-meetingControl
- index.html
- static文件夹
- monitor包:/data/services/web/pc/pc-vue2-moniter
- index.html
- static文件夹
- module文件夹
- platform包:/data/services/web/pc/pc-vue2-platform
- index.html
- static文件夹
- temp文件夹
- voice包:/data/services/web/pc/pc-vue2-voice/pc-vue2-voice
- index.html
- static文件夹
- h5-meeting:/data/services/web/h5/h5-uniapp-meeting
- index.html
- static文件夹
- h5-moniter:/data/services/web/h5/h5-uniapp-moniter
- index.html
- static文件夹
- h5-platform-mobile:/data/services/web/h5/h5-uniapp-platform/meeting-mobile
- assets文件夹
- index.html
- static文件夹
- h5-platform-platform-mobile:/data/services/web/h5/h5-uniapp-platform/unified-platform-mobile
- index.html
- static文件夹
### 后端服务目录:
- auth包:/data/services/api/auth/auth-sso-auth
- ubains-auth.jar
- gatway包:/data/services/api/auth/auth-sso-gatway
- ubains-gateway.jar
- system包:/data/services/api/auth/auth-sso-system
- ubains-modules-system.jar
- java2.0包:/data/services/api/java-meeting/java-meeting2.0
- ubains-meeting-inner-api-1.0-SNAPSHOT.jar
- java-extapi包:/data/services/api/java-meeting/java-meeting-extapi
- ubains-meeting-api-1.0-SNAPSHOT.jar
- java-scheduling包:/data/services/api/java-meeting/java-message-scheduling
- ubains-meeting-message-scheduling-1.0-SNAPSHOT.jar
- java-mqtt包:/data/services/api/java-meeting/java-mqtt
- ubains-meeting-mqtt-1.0-SNAPSHOT.jar
- java-quartz包:/data/services/api/java-meeting/java-quartz
- ubains-meeting-quartz-1.0-SNAPSHOT.jar
- cmdb包:/data/services/api/python-cmdb
- cmdb文件夹
- UbainsDevOps文件夹
- voice包:/data/services/api/python-voice
- UbainsDevOps文件夹
- uvoice文件夹
## 更新操作流程
- 更新方式:每次全量更新所有服务包
- 传输方式:通过SCP从测试服务器传输到其他环境服务器
1. 在测试服务器上提取所有服务包,压缩成tar.gz格式(命名为`services_update.tar.gz`),通过SCP传输到其他环境服务器的`/data/`目录下。
2. 在其他环境服务器上解压缩tar.gz格式包。
3. 备份其他环境服务器上原服务文件,然后执行服务包更新替换操作。
4. 更新替换完成后,使用其他环境服务器原配置文件进行恢复:
- 前端包:恢复`static/config.json`文件(从原服务包static目录中取回)
- cmdb包:恢复`bus/config/settingbus.conf`文件(从原服务包对应路径取回)
- uvoice包:恢复`bus/config/settingbus.conf`文件(从原服务包对应路径取回)
5. 重启服务:
- 执行`docker restart ujava2 upython upython_voice`
- 检测容器启动是否正常(通过进程检测:`docker exec ujava2 ps -ef``docker exec upython ps -ef``docker exec upython_voice ps -ef`
6. 清理临时文件:
- 清理测试服务器上的临时打包文件
- 清理其他环境服务器上的临时解压文件和压缩包
7. 生成报告文件,存储到本地执行脚本的机器上。
## 异常处理
- 更新前检查:
- 检查目标服务器磁盘空间是否充足
- 检查SSH连接是否正常
- 检查目标服务器上Docker服务是否运行
- 覆盖失败处理:
- 在服务包覆盖过程中如果覆盖失败(如中断),立即停止本次更新操作,输出失败的文件名和失败原因。
- 因更新前已备份原服务文件,覆盖失败后可通过备份文件进行回滚恢复。
- 网络中断处理:
- SCP传输过程中网络中断,支持断点重传或重新执行传输操作。
- SSH连接断开后可重新执行更新操作来恢复。
- 覆盖中断后可重新执行覆盖操作来恢复。
## 更新说明
- 前端包更新说明:
- 更新前备份其他环境服务器上原`static/config.json`文件。
- 覆盖新服务包后,将原`static/config.json`文件恢复到新服务包的`static`目录下。
- jar格式后端包更新说明:
- 直接覆盖更新,无需恢复配置文件。
- cmdb文件夹后端包更新说明:
- 更新前备份原文件夹下`bus/config/settingbus.conf`文件。
- 覆盖新服务包后,将原`bus/config/settingbus.conf`恢复到新服务包原路径。
- uvoice文件夹后端包更新说明:
- 更新前备份原文件夹下`bus/config/settingbus.conf`文件。
- 覆盖新服务包后,将原`bus/config/settingbus.conf`恢复到新服务包原路径。
- 需要恢复的配置文件清单:`config.json``settingbus.conf`(无其他配置文件需要恢复)。
- 测试服务器与其他环境服务器存在路径差异(如`pc-vue2-meetngV2``pc-vue2-meetingV2`),脚本中需按照本文档中各服务器目录信息分别处理。
## 核验材料
1. 所有的操作需日志记录说明
2. 所有操作结束后需输出报告说明,报告以md格式存储到本地执行脚本的机器上。
- ~~需记录新旧版本对比~~(暂不实现,后续版本补充)
- 需记录所有操作的详细步骤
- 记录服务包的大小
- 操作耗时
- 更新成功或失败的状态
## 代码架构
本需求对应的脚本和配置文件位于 `AuxiliaryTool/ScriptTool/RemoteUpdate/` 目录下,与其他需求共享该目录,请注意区分,避免互相修改。
```
AuxiliaryTool/ScriptTool/RemoteUpdate/
├── x86_env_update.py ← 本需求使用的脚本(X86自动更新环境版本)
├── update_config.json ← 本需求使用的配置文件
├── x86_package_update.py ← 其他需求使用(X86/ARM自动更新部署包版本、远程自动化部署),请勿修改
├── config.json ← 其他需求使用(X86/ARM自动更新部署包版本),请勿修改
└── reports/ ← 共用报告输出目录
```
**注意事项:**
- `x86_env_update.py` 为本需求专用脚本,独立运行,不影响其他需求。
- `x86_package_update.py``config.json` 为"自动更新部署包版本"和"远程自动化部署"需求使用,本需求不应修改。
- 如需扩展(如支持多台目标服务器、ARM架构环境更新),通过 `update_config.json` 配置化实现。
# 计划执行_X86自动更新环境版本
> 版本:V1.0
> 创建日期:2026-06-06
> 基于文档:`_PRD_X86自动更新环境版本_需求文档.md`
> 状态:**已实施**
> 交付物:
> - `x86_env_update.py` (环境版本更新主脚本)
> - `update_config.json` (更新配置文件)
> - `reports/` (报告输出目录)
---
## 一、任务概述
从测试服务器(192.168.5.44)提取全量服务包,通过SCP传输到其他环境服务器(如192.168.5.46),在目标服务器上执行备份、更新替换、配置恢复、容器重启,最终生成更新报告。脚本存储在本地执行,全程日志记录。
### 目标服务器信息
| 项目 | 测试服务器(服务来源) | 其他环境服务器(更新目标) |
|------|----------------------|--------------------------|
| IP | 192.168.5.44 | 192.168.5.46(配置化,可扩展) |
| 端口 | 22 | 22 |
| 账号 | root | root |
| 密码 | Ubains@123 | Ubains@123 |
| 用途 | 提取最新服务包 | 接收并更新服务包 |
### 与现有代码的关系
| 对比项 | 现有 `x86_package_update.py` | 新脚本 `x86_env_update.py` |
|--------|------------------------------|---------------------------|
| 目的 | 更新部署包 → 重新打包 → 上传网盘 → 远程部署 | 直接从测试环境更新到其他环境 |
| 涉及服务器 | 测试 + 打包 + 多台部署目标 | 测试 + 其他环境服务器 |
| 流程 | 11步(含网盘下载/上传、重新打包) | 7步(打包→传输→更新→重启) |
| 是否重建部署包 | 是 | 否,直接覆盖目标服务器服务文件 |
| 配置文件 | `config.json` | `update_config.json`(独立配置) |
---
## 二、执行阶段划分
### 阶段一:前置检查(预计1-2分钟)
| 序号 | 步骤 | 描述 | 验证标准 |
|------|------|------|----------|
| 1.1 | 加载配置 | 读取 `update_config.json`,解析服务器信息和服务列表 | 配置格式正确,无缺失字段 |
| 1.2 | SSH连接测试服务器 | paramiko连接192.168.5.44,重试3次 | 连接成功 |
| 1.3 | SSH连接目标服务器 | paramiko连接目标服务器,重试3次 | 连接成功 |
| 1.4 | 目标服务器磁盘检查 | `df -h /data` 检查磁盘空间 | 可用空间 ≥ 服务包大小 × 2 |
| 1.5 | Docker服务检查 | 目标服务器执行 `docker info` | Docker服务运行正常 |
**前置检查失败处理**:任一项检查失败,输出明确错误信息并终止执行。
---
### 阶段二:服务包打包与传输(预计5-15分钟,取决于网络和服务包大小)
| 序号 | 步骤 | 描述 | 执行方式 |
|------|------|------|----------|
| 2.1 | 验证测试服务器目录 | 检查所有服务目录是否存在于测试服务器 | `cd /data/services && for d in ... do [ -d "$d" ]` |
| 2.2 | 打包服务文件 | 在测试服务器上执行 `tar -czf` 全量打包 | `tar --warning=no-file-changed -czf /tmp/services_update.tar.gz ...` |
| 2.3 | SCP传输到目标服务器 | 从测试服务器直接传输到目标服务器(优先服务器间互传,回退本地中转) | `paramiko` SFTP 或管道传输 |
| 2.4 | 清理测试服务器临时文件 | 删除测试服务器上的 `/tmp/services_update.tar.gz` | `rm -f` |
**路径映射处理**
| 测试服务器路径 | 目标服务器路径 | 映射说明 |
|---------------|---------------|----------|
| `web/pc/pc-vue2-meetngV2` | `web/pc/pc-vue2-meetingV2` | meetng → meeting |
| `web/pc/pc-vue2-meetngV3` | `web/pc/pc-vue2-meetingV3` | meetng → meeting |
路径映射在传输到目标服务器后、解压时通过重命名处理,确保服务文件落到正确目录。
**传输策略**
1. 优先:测试服务器到目标服务器间SSH管道直传(最快,不经过本地)
2. 回退:测试服务器 → 本地 → 目标服务器(本地中转)
---
### 阶段三:备份原服务文件(预计3-5分钟)
| 序号 | 步骤 | 描述 | 备份路径 |
|------|------|------|----------|
| 3.1 | 创建备份目录 | 在目标服务器创建带时间戳的备份目录 | `/data/services_backup_<timestamp>/` |
| 3.2 | 备份前端服务 | 备份所有前端服务目录(含config.json) | 备份目录下对应路径 |
| 3.3 | 备份后端jar服务 | 备份所有jar文件 | 备份目录下对应路径 |
| 3.4 | 备份后端文件夹服务 | 备份cmdb和voice目录(含settingbus.conf) | 备份目录下对应路径 |
| 3.5 | 验证备份完整性 | 检查备份目录文件数量和大小 | 备份文件数量与原目录一致 |
**备份说明**
- 备份是更新前的安全措施,确保失败后可回滚
- 备份包含完整的服务文件(含配置文件),回滚时直接恢复整个目录/文件即可
- 配置文件(config.json、settingbus.conf)从备份中恢复,不单独提取
---
### 阶段四:服务包更新替换与配置恢复(预计5-10分钟)
| 序号 | 步骤 | 描述 | 详细操作 |
|------|------|------|----------|
| 4.1 | 解压服务包 | 在目标服务器解压 `services_update.tar.gz` | `tar -xzf -C /data/services_update/` |
| 4.2 | 路径映射重命名 | 将测试服务器路径名映射为目标服务器路径名 | `mv meetngV2 → meetingV2` |
| 4.3 | 更新前端服务 | 逐个覆盖前端目录 | `rm -rf 目标目录 && cp -r 新目录 目标目录` |
| 4.4 | 恢复前端config.json | 从备份恢复每个前端的 `static/config.json` | `cp 备份/config.json 目标/static/config.json` |
| 4.5 | 更新后端jar服务 | 逐个覆盖jar文件 | `cp -f 新jar 目标jar` |
| 4.6 | 更新后端文件夹服务 | 覆盖cmdb和voice目录 | `rm -rf 目标目录 && cp -r 新目录 目标目录` |
| 4.7 | 恢复settingbus.conf | 从备份恢复cmdb和voice的 `bus/config/settingbus.conf` | `cp 备份/settingbus.conf 目标/bus/config/` |
| 4.8 | 清理解压临时目录 | 删除目标服务器上的 `/data/services_update/` | `rm -rf /data/services_update/` |
**配置恢复逻辑**
```
备份原服务文件(含配置) → 覆盖新服务文件 → 从备份恢复配置文件
```
| 服务类型 | 需恢复的配置文件 | 配置文件路径 |
|---------|----------------|-------------|
| 前端服务(13个) | `config.json` | `<服务目录>/static/config.json` |
| jar后端服务(8个) | 无需恢复 | — |
| cmdb文件夹 | `settingbus.conf` | `cmdb/bus/config/settingbus.conf` |
| voice文件夹 | `settingbus.conf` | `uvoice/bus/config/settingbus.conf` |
**覆盖失败处理**
- 任一服务覆盖失败 → 立即停止 → 输出失败文件名和原因
- 已有备份,可手动回滚或重新执行脚本恢复
---
### 阶段五:容器重启与检测(预计5-10分钟)
| 序号 | 步骤 | 描述 | 验证方式 |
|------|------|------|----------|
| 5.1 | 重启Docker容器 | `docker restart ujava2 upython upython_voice` | 命令执行成功 |
| 5.2 | 等待容器启动 | 等待30秒让容器初始化 | `sleep 30` |
| 5.3 | 进程检测-ujava2 | `docker exec ujava2 ps -ef` | 进程列表非空,无异常退出 |
| 5.4 | 进程检测-upython | `docker exec upython ps -ef` | 进程列表非空,无异常退出 |
| 5.5 | 进程检测-upython_voice | `docker exec upython_voice ps -ef` | 进程列表非空,无异常退出 |
**检测说明**
- 当前仅通过进程检测判断服务状态
- 后续版本可扩展端口检测和健康检查接口
---
### 阶段六:清理与报告生成(预计1-2分钟)
| 序号 | 步骤 | 描述 |
|------|------|------|
| 6.1 | 清理目标服务器 | 删除 `/data/services_update.tar.gz`、解压临时目录 |
| 6.2 | 清理测试服务器 | 删除 `/tmp/services_update.tar.gz`(如存在) |
| 6.3 | 生成更新报告 | 以md格式生成报告,存储到本地 `reports/` 目录 |
| 6.4 | 断开SSH连接 | 关闭所有SSH/SFTP连接 |
---
## 三、配置文件设计
`update_config.json` 结构设计:
```json
{
"test_server": {
"host": "192.168.5.44",
"port": 22,
"username": "root",
"password": "Ubains@123"
},
"target_servers": [
{
"name": "其他环境服务器",
"host": "192.168.5.46",
"port": 22,
"username": "root",
"password": "Ubains@123"
}
],
"services": {
"frontend": [
{"name": "ai包", "path": "web/pc/pc-vue2-ai", "config_file": "static/config.json"},
{"name": "后台包", "path": "web/pc/pc-vue2-backstage", "config_file": "static/config.json"}
],
"backend_jar": [
{"name": "auth包", "path": "api/auth/auth-sso-auth", "file": "ubains-auth.jar"}
],
"backend_folder": [
{"name": "cmdb包", "path": "api/python-cmdb", "config_file": "cmdb/bus/config/settingbus.conf"},
{"name": "voice包", "path": "api/python-voice", "config_file": "uvoice/bus/config/settingbus.conf"}
]
},
"path_mapping": {
"web/pc/pc-vue2-meetngV2": "web/pc/pc-vue2-meetingV2",
"web/pc/pc-vue2-meetngV3": "web/pc/pc-vue2-meetingV3"
},
"containers": ["ujava2", "upython", "upython_voice"],
"service_base_dir": "/data/services"
}
```
---
## 四、报告内容设计
报告以Markdown格式输出,包含以下章节:
| 章节 | 内容 |
|------|------|
| 基本信息 | 执行时间、结束时间、总耗时、执行状态 |
| 目标服务器信息 | IP、更新状态 |
| 操作步骤记录 | 序号、步骤名称、时间、状态、详情(表格形式) |
| 服务更新详情 | 服务名称、类型、更新前大小、更新后大小、状态(表格形式) |
| 容器重启结果 | 容器名称、重启状态、进程检测结果 |
| 异常记录 | 失败的服务和原因 |
| 备份信息 | 备份目录路径、备份时间 |
**报告命名规则**`X86_<目标IP>_环境版本更新报告_<日期>.md`
**报告存储位置**`AuxiliaryTool/ScriptTool/RemoteUpdate/reports/`
---
## 五、脚本执行方式
```bash
# 完整更新流程
python x86_env_update.py
# 指定配置文件
python x86_env_update.py --config update_config.json
# 仅执行前置检查(不实际更新)
python x86_env_update.py --check-only
# 跳过容器重启步骤
python x86_env_update.py --skip-restart
# 查看帮助
python x86_env_update.py --help
```
---
## 六、涉及文件
| 序号 | 文件路径 | 操作 | 说明 |
|------|---------|------|------|
| 1 | `AuxiliaryTool/ScriptTool/RemoteUpdate/x86_env_update.py` | 新增 | 环境版本更新主脚本 |
| 2 | `AuxiliaryTool/ScriptTool/RemoteUpdate/update_config.json` | 新增 | 更新专用配置文件 |
| 3 | `AuxiliaryTool/ScriptTool/RemoteUpdate/reports/` | 使用 | 报告输出目录 |
> 现有 `x86_package_update.py` 和 `config.json` 不修改,新脚本独立运行。
---
## 七、风险评估
| 风险 | 等级 | 影响 | 应对措施 |
|------|------|------|---------|
| 服务包打包过程中测试服务目录被写入 | 低 | tar警告但不影响打包 | 使用 `--warning=no-file-changed` 忽略 |
| SCP传输网络中断 | 中 | 传输失败 | 支持重新执行;备份已存在可回滚 |
| 覆盖过程中SSH断开 | 高 | 部分服务已替换、部分未替换 | 已备份原服务文件,可手动回滚 |
| 配置文件恢复失败 | 高 | 目标服务器配置错误 | 从备份目录恢复;脚本终止并提示 |
| 容器重启后进程未启动 | 中 | 服务不可用 | 报告中标记失败,需人工排查容器日志 |
| 测试与目标服务器路径差异 | 中 | 文件覆盖到错误位置 | 配置文件中定义path_mapping,脚本自动映射 |
| 磁盘空间不足 | 中 | 打包/解压失败 | 前置检查磁盘空间,预留2倍余量 |
| Docker服务异常 | 低 | 重启失败 | 前置检查Docker状态 |
---
## 八、验收标准
- [ ] 配置文件 `update_config.json` 格式正确,包含所有服务定义和路径映射
- [ ] SSH连接测试服务器和目标服务器成功(含重试机制)
- [ ] 前置检查通过(磁盘空间、Docker状态)
- [ ] 测试服务器服务目录验证正确
- [ ] 全量服务包打包成功,记录文件大小
- [ ] SCP传输到目标服务器成功
- [ ] 测试服务器临时文件已清理
- [ ] 目标服务器原服务文件完整备份
- [ ] 所有前端服务更新成功,`config.json` 从备份恢复
- [ ] 所有jar后端服务直接覆盖更新成功
- [ ] cmdb和voice文件夹更新成功,`settingbus.conf` 从备份恢复
- [ ] 三个容器(ujava2、upython、upython_voice)重启成功
- [ ] 容器内进程检测通过
- [ ] 目标服务器临时文件已清理
- [ ] 报告以md格式存储到本地 `reports/` 目录
- [ ] 报告包含:操作步骤、服务更新详情、服务包大小、操作耗时、成功/失败状态
- [ ] 全程日志记录完整
---
## 九、实施记录
| 日期 | 任务 | 结果 | 备注 |
|------|------|------|------|
| 2026-06-06 | 创建 update_config.json | ✅ 完成 | 包含13个前端、8个jar、2个文件夹服务定义 |
| 2026-06-06 | 创建 x86_env_update.py | ✅ 完成 | 6个阶段完整实现,支持--check-only/--skip-restart参数 |
---
## 十、后续工作
- [ ] 新旧版本对比功能(待定义版本号获取方式)
- [ ] 容器健康检查扩展(端口检测、API接口验证)
- [ ] 支持多台目标服务器批量更新
- [ ] 支持选择性更新(只更新指定的服务包)
- [ ] 备份文件自动清理策略(保留最近N次备份)
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论