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

feat(service): 优化服务自检功能并调整ERP对接参数

- 新增安全合规检测模块,包含弱密码检测和安全基线检查
- 实现MySQL和Redis深度检测功能,包括性能指标和状态监控
- 添加服务器资源增强检测,涵盖inode使用率、TCP状态等
- 更新文档状态标记,完善服务自检执行计划
- 调整ERP对接参数,移除不支持的创建人员传参功能
- 注明创建报告接口暂不支持createuser_name/createuser_id参数
上级 d140c5e1
......@@ -214,7 +214,8 @@ def generate_report_main(
buglist_path: str,
output_path: Optional[str] = None,
project_name: Optional[str] = None,
report_format: str = "docx"
report_format: str = "docx",
skip_erp_prompt: bool = False
) -> List[str]:
"""
生成报告的主函数
......@@ -225,6 +226,7 @@ def generate_report_main(
output_path: 输出报告文件路径(可选)
project_name: 项目名称(可选)
report_format: 报告格式(docx/md/both)
skip_erp_prompt: 是否跳过CLI的ERP上传交互(GUI模式应设为True,由GUI自行处理上传对话框)
Returns:
生成的报告文件路径列表
......@@ -345,6 +347,10 @@ def generate_report_main(
print(f" - {report}")
# ===== ERP上传功能 =====
# GUI模式跳过CLI交互,由GUI自行处理上传对话框
if skip_erp_prompt:
return generated_reports
# 检查是否有docx格式的报告
docx_reports = [r for r in generated_reports if r.endswith('.docx')]
......@@ -355,11 +361,16 @@ def generate_report_main(
logger = setup_logger()
for report_path in docx_reports:
# 询问用户是否上传,并获取测试单ID
confirmed, testing_id = ask_upload_confirmation_cli(report_path)
# 询问用户是否上传,获取测试单ID和抄送人列表
confirmed, testing_id, copyuser_ids = ask_upload_confirmation_cli(report_path)
if confirmed:
print(f"\n正在上传报告到ERP(测试单ID: {testing_id})...")
success = upload_report_to_erp(report_path, logger, developtesting_id=testing_id)
success = upload_report_to_erp(
report_path,
logger,
developtesting_id=testing_id,
copyuser_list=copyuser_ids
)
if success:
print(f" [OK] 报告已成功上传到ERP(测试单ID: {testing_id})")
else:
......
......@@ -329,6 +329,7 @@ ERP_MAX_RETRIES = 3
# ERP接口路径
ERP_UPLOAD_IMAGE_URL = "/openclaw/upload/richtext" # 上传图片接口路径
ERP_CREATE_REPORT_URL = "/openclaw/report" # 创建报告接口路径
ERP_STUFF_URL = "/openclaw/stuff" # 获取人员列表接口路径
# 请求超时时间(秒)
ERP_REQUEST_TIMEOUT = 30
......
......@@ -27,6 +27,7 @@ from src.config import (
ERP_MAX_RETRIES,
ERP_UPLOAD_IMAGE_URL,
ERP_CREATE_REPORT_URL,
ERP_STUFF_URL,
ERP_REQUEST_TIMEOUT,
ERP_SAVE_IMAGES_TEMP,
ERP_VALIDATE_IMAGES,
......@@ -124,6 +125,119 @@ def validate_and_save_image(img_bytes: bytes, idx: int, content_type: str, logge
return True
def get_stuff_list(logger: logging.Logger) -> Optional[list]:
"""
获取ERP系统人员列表(含重试机制)
通过 GET /openclaw/stuff 接口获取所有人员信息,
用于将姓名转换为人员ID。
Args:
logger: 日志记录器
Returns:
人员列表,格式: [{"id": 1, "name": "超管员", "department_id": null}, ...]
失败返回None
"""
url = f'{ERP_BASE_URL}{ERP_STUFF_URL}'
for attempt in range(ERP_MAX_RETRIES):
try:
# 准备请求参数
headers = {'X-Api-Key': ERP_API_KEY}
# 打印请求信息
logger.info(f"=== 获取人员列表请求 ===")
logger.info(f"请求URL: {url}")
# 发送请求
resp = requests.get(
url,
headers=headers,
timeout=ERP_REQUEST_TIMEOUT
)
# 打印响应信息
logger.info(f"响应状态码: {resp.status_code}")
logger.info(f"响应内容: {resp.text[:200]}..." if len(resp.text) > 200 else f"响应内容: {resp.text}")
result = resp.json()
if result.get('success') == 1:
stuff_list = result['data']
logger.info(f"✓ 获取人员列表成功,共 {len(stuff_list)} 人")
# 打印人员列表供用户参考
for s in stuff_list:
logger.info(f" - {s['name']} (ID: {s['id']})")
return stuff_list
else:
error_code = result.get('error', 0)
# 4xx错误不重试
if 400 <= error_code < 500:
logger.error(f"✗ 获取人员列表失败(客户端错误 {error_code}): {result.get('msg')}")
return None
if attempt < ERP_MAX_RETRIES - 1:
logger.warning(f"获取人员列表失败,{ERP_RETRY_INTERVAL}秒后重试({attempt+1}/{ERP_MAX_RETRIES}): {result.get('msg')}")
time.sleep(ERP_RETRY_INTERVAL)
else:
logger.error(f"✗ 获取人员列表失败(已达最大重试次数): {result.get('msg')}")
except requests.exceptions.Timeout:
logger.warning(f"获取人员列表超时,{ERP_RETRY_INTERVAL}秒后重试({attempt+1}/{ERP_MAX_RETRIES})")
if attempt < ERP_MAX_RETRIES - 1:
time.sleep(ERP_RETRY_INTERVAL)
else:
logger.error(f"✗ 获取人员列表超时(已达最大重试次数)")
except Exception as e:
logger.error(f"✗ 获取人员列表异常: {str(e)}")
break
return None
def match_names_to_ids(names: list, stuff_list: list, logger: logging.Logger) -> list:
"""
将姓名列表匹配为人员ID列表
根据用户输入的姓名,从获取到的人员列表中查找对应的ID,
用于构造创建报告接口的 copyuserList 参数。
Args:
names: 用户输入的姓名列表,如 ["黄史恭", "欧阳友平"]
stuff_list: 人员列表数据(来自 get_stuff_list)
logger: 日志记录器
Returns:
匹配成功的人员ID列表,如 [24, 31]
"""
matched_ids = []
not_found_names = []
for name in names:
name = name.strip()
if not name:
continue
# 在人员列表中查找匹配
found = False
for person in stuff_list:
if person['name'] == name:
matched_ids.append(person['id'])
logger.info(f" ✓ 抄送人匹配: {name} → ID: {person['id']}")
found = True
break
if not found:
not_found_names.append(name)
logger.warning(f" ✗ 未找到匹配人员: {name}")
if not_found_names:
logger.warning(f"以下人员未在系统中找到: {', '.join(not_found_names)}")
return matched_ids
def upload_image_to_erp(img_bytes: bytes, idx: int, logger: logging.Logger) -> Optional[str]:
"""
上传单张图片到ERP(含重试机制)
......@@ -199,7 +313,7 @@ def upload_image_to_erp(img_bytes: bytes, idx: int, logger: logging.Logger) -> O
return None
def create_report_in_erp(content: str, logger: logging.Logger, developtesting_id: int = None) -> Optional[int]:
def create_report_in_erp(content: str, logger: logging.Logger, developtesting_id: int = None, copyuser_list: Optional[list] = None) -> Optional[int]:
"""
在ERP中创建测试报告(含重试机制)
......@@ -207,6 +321,7 @@ def create_report_in_erp(content: str, logger: logging.Logger, developtesting_id
content: HTML格式的报告内容
logger: 日志记录器
developtesting_id: 测试单ID(可选,默认使用config中的配置)
copyuser_list: 抄送人ID列表(可选,默认为空列表)
Returns:
报告ID,失败返回None
......@@ -215,6 +330,10 @@ def create_report_in_erp(content: str, logger: logging.Logger, developtesting_id
if developtesting_id is None:
developtesting_id = ERP_DEVELOPTESTING_ID
# 如果没有指定抄送人列表,默认为空
if copyuser_list is None:
copyuser_list = []
url = f'{ERP_BASE_URL}{ERP_CREATE_REPORT_URL}'
for attempt in range(ERP_MAX_RETRIES):
......@@ -225,7 +344,7 @@ def create_report_in_erp(content: str, logger: logging.Logger, developtesting_id
'type_id': ERP_TYPE_ID,
'content': content,
'descript': '',
'copyuserList': []
'copyuserList': copyuser_list
}
headers = {
......@@ -242,7 +361,7 @@ def create_report_in_erp(content: str, logger: logging.Logger, developtesting_id
logger.info(f" - type_id: {ERP_TYPE_ID}")
logger.info(f" - content: {len(content)} 字符 (HTML)")
logger.info(f" - descript: (空)")
logger.info(f" - copyuserList: (空)")
logger.info(f" - copyuserList: {copyuser_list}")
# 发送请求
resp = requests.post(
......@@ -379,7 +498,7 @@ def word_to_html_with_images(file_path: str, logger: logging.Logger) -> Optional
return None
def upload_report_to_erp(file_path: str, logger: logging.Logger = None, developtesting_id: int = None) -> bool:
def upload_report_to_erp(file_path: str, logger: logging.Logger = None, developtesting_id: int = None, copyuser_list: Optional[list] = None) -> bool:
"""
上传报告到ERP的完整流程
......@@ -387,6 +506,7 @@ def upload_report_to_erp(file_path: str, logger: logging.Logger = None, developt
file_path: Word报告文件路径
logger: 日志记录器(可选,默认创建新的logger)
developtesting_id: 测试单ID(可选,默认使用config中的配置)
copyuser_list: 抄送人ID列表(可选,默认为空列表)
Returns:
是否上传成功
......@@ -418,7 +538,7 @@ def upload_report_to_erp(file_path: str, logger: logging.Logger = None, developt
# 2. 创建ERP报告
logger.info(f"[步骤2/2] 创建ERP测试报告")
report_id = create_report_in_erp(html_content, logger, developtesting_id=developtesting_id)
report_id = create_report_in_erp(html_content, logger, developtesting_id=developtesting_id, copyuser_list=copyuser_list)
if report_id is None:
logger.error("✗ ERP报告创建失败")
return False
......@@ -430,17 +550,17 @@ def upload_report_to_erp(file_path: str, logger: logging.Logger = None, developt
return True
def ask_upload_confirmation_cli(report_path: str) -> Tuple[bool, Optional[int]]:
def ask_upload_confirmation_cli(report_path: str) -> Tuple[bool, Optional[int], Optional[list]]:
"""
控制台交互:询问用户是否上传报告到ERP,并输入测试单ID
控制台交互:询问用户是否上传报告到ERP,输入测试单ID和抄送人
Args:
report_path: 报告文件路径
Returns:
元组 (用户选择, 测试单ID)
- (True, int) 用户确认上传并提供了测试单ID
- (False, None) 用户选择不上传
元组 (用户选择, 测试单ID, 抄送人ID列表)
- (True, int, list) 用户确认上传
- (False, None, None) 用户选择不上传
"""
while True:
print(f"\n{'='*50}")
......@@ -453,22 +573,46 @@ def ask_upload_confirmation_cli(report_path: str) -> Tuple[bool, Optional[int]]:
choice = 'N'
if choice == 'Y':
# 用户确认上传,继续输入测试单ID
# 输入测试单ID
testing_id = None
while True:
id_input = input(f"请输入测试单ID (默认={ERP_DEVELOPTESTING_ID}): ").strip()
if not id_input:
# 按回车使用默认值
return True, ERP_DEVELOPTESTING_ID
testing_id = ERP_DEVELOPTESTING_ID
break
try:
testing_id = int(id_input)
if testing_id > 0:
return True, testing_id
break
else:
print("测试单ID必须为正整数,请重新输入")
except ValueError:
print("输入无效,请输入数字")
# 输入抄送人姓名
copyuser_ids = None
copyuser_input = input("请输入抄送人姓名(多人用逗号分隔,直接回车跳过): ").strip()
if copyuser_input:
logger = setup_logger()
# 获取人员列表
stuff_list = get_stuff_list(logger)
if stuff_list:
names = [n.strip() for n in copyuser_input.split(',')]
copyuser_ids = match_names_to_ids(names, stuff_list, logger)
if copyuser_ids:
print(f" 匹配到抄送人ID: {copyuser_ids}")
else:
print(" 未匹配到任何抄送人,将以空列表提交")
copyuser_ids = []
else:
print(" 获取人员列表失败,将以空列表提交")
copyuser_ids = []
return True, testing_id, copyuser_ids
elif choice == 'N':
return False, None
return False, None, None
else:
print("输入错误,请输入 Y 或 N")
......
......@@ -451,13 +451,14 @@ class ReportGeneratorGUI:
# 重定向到GUI
sys.stdout = TextRedirector(self.log_text, "info")
# 调用生成函数
# 调用生成函数(跳过CLI的ERP交互,由GUI自行处理上传对话框)
result_paths = generate_report_main(
testcase_path=testcase_path,
buglist_path=buglist_path,
output_path=output_path if output_path else None,
project_name=project_name,
report_format=report_format
report_format=report_format,
skip_erp_prompt=True
)
# 恢复原始stdout
......@@ -496,31 +497,31 @@ class ReportGeneratorGUI:
def _ask_upload_to_erp(self, report_path: str):
"""
询问用户是否上传报告到ERP,并获取测试单ID
询问用户是否上传报告到ERP,并获取测试单ID和抄送人
Args:
report_path: 报告文件路径
"""
# 使用自定义对话框替代messagebox,确保按钮正常显示
confirmed, testing_id = self._show_upload_dialog(report_path)
confirmed, testing_id, copyuser_ids = self._show_upload_dialog(report_path)
if confirmed:
# 用户选择上传,透传测试单ID
self._upload_to_erp(report_path, developtesting_id=testing_id)
# 用户选择上传,透传测试单ID和抄送人列表
self._upload_to_erp(report_path, developtesting_id=testing_id, copyuser_list=copyuser_ids)
else:
self._log("[!] 跳过上传到ERP", "warning")
def _show_upload_dialog(self, report_path: str) -> tuple:
"""
显示自定义上传确认对话框(含测试单ID输入框)
显示自定义上传确认对话框(含测试单ID输入框和抄送人输入框
Args:
report_path: 报告文件路径
Returns:
元组 (用户选择, 测试单ID)
- (True, int) 用户确认上传并提供了测试单ID
- (False, None) 用户选择不上传
元组 (用户选择, 测试单ID, 抄送人ID列表)
- (True, int, list) 用户确认上传
- (False, None, None) 用户选择不上传
"""
# 导入ERP配置
from src.config import ERP_DEVELOPTESTING_ID
......@@ -535,7 +536,7 @@ class ReportGeneratorGUI:
dialog.grab_set()
# 先设置一个临时大小
dialog.geometry("550x280+300+200")
dialog.geometry("600x380+300+200")
# 消息内容
message_frame = tk.Frame(dialog, padx=30, pady=15)
......@@ -579,27 +580,97 @@ class ReportGeneratorGUI:
fg="gray"
).pack(side=tk.LEFT, padx=(10, 0))
# 抄送人输入区域
copyuser_frame = tk.Frame(message_frame)
copyuser_frame.pack(anchor=tk.W, pady=(10, 0))
tk.Label(
copyuser_frame,
text="抄送人:",
font=("微软雅黑", 10),
).pack(side=tk.LEFT)
copyuser_entry = tk.Entry(copyuser_frame, width=25, font=("微软雅黑", 10))
copyuser_entry.pack(side=tk.LEFT, padx=(5, 0))
tk.Label(
copyuser_frame,
text="(多人用逗号分隔,可空)",
font=("微软雅黑", 8),
fg="gray"
).pack(side=tk.LEFT, padx=(10, 0))
# 获取人员列表按钮和展示区域
stuff_frame = tk.Frame(message_frame)
stuff_frame.pack(anchor=tk.W, pady=(5, 0))
# 用于展示人员列表的文本区域(初始隐藏)
stuff_text = tk.Text(
stuff_frame,
font=("微软雅黑", 9),
height=5,
width=50,
state=tk.DISABLED,
bg="#f5f5f5"
)
def on_fetch_stuff():
"""点击按钮获取人员列表"""
try:
from src.erp_uploader import get_stuff_list, setup_logger
logger = setup_logger()
stuff_list = get_stuff_list(logger)
if stuff_list:
# 显示人员列表
stuff_text.config(state=tk.NORMAL)
stuff_text.delete(1.0, tk.END)
stuff_text.insert(tk.END, "可抄送人员列表:\n")
for s in stuff_list:
stuff_text.insert(tk.END, f" {s['name']} (ID: {s['id']})\n")
stuff_text.config(state=tk.DISABLED)
stuff_text.pack(side=tk.LEFT, padx=(0, 5), fill=tk.X, expand=True)
else:
stuff_text.config(state=tk.NORMAL)
stuff_text.delete(1.0, tk.END)
stuff_text.insert(tk.END, "获取人员列表失败,请检查网络连接")
stuff_text.config(state=tk.DISABLED)
stuff_text.pack(side=tk.LEFT, padx=(0, 5), fill=tk.X, expand=True)
except Exception as e:
stuff_text.config(state=tk.NORMAL)
stuff_text.delete(1.0, tk.END)
stuff_text.insert(tk.END, f"获取人员列表异常: {str(e)}")
stuff_text.config(state=tk.DISABLED)
stuff_text.pack(side=tk.LEFT, padx=(0, 5), fill=tk.X, expand=True)
tk.Button(
stuff_frame,
text="查看人员列表",
command=on_fetch_stuff,
font=("微软雅黑", 9),
bg="#3498db",
fg="white",
relief=tk.RAISED,
cursor="hand2",
padx=10
).pack(side=tk.LEFT, padx=(0, 5))
# 按钮区域(放在dialog的底部,固定高度)
button_frame = tk.Frame(dialog, bg="#f0f0f0", height=60)
button_frame.pack(side=tk.BOTTOM, fill=tk.X)
result = [False, None]
# 结果存储: [confirmed, testing_id, copyuser_ids]
result = [False, None, None]
def on_yes():
"""确认上传"""
# 获取用户输入的测试单ID
id_text = id_entry.get().strip()
if not id_text:
# 空值使用默认值
result[0] = True
result[1] = ERP_DEVELOPTESTING_ID
testing_id = ERP_DEVELOPTESTING_ID
else:
try:
testing_id = int(id_text)
if testing_id > 0:
result[0] = True
result[1] = testing_id
else:
# 无效输入,提示用户
if testing_id <= 0:
from tkinter import messagebox
messagebox.showwarning("输入错误", "测试单ID必须为正整数", parent=dialog)
return
......@@ -607,11 +678,35 @@ class ReportGeneratorGUI:
from tkinter import messagebox
messagebox.showwarning("输入错误", "请输入有效的数字", parent=dialog)
return
# 获取抄送人输入并匹配ID
copyuser_ids = None
copyuser_input = copyuser_entry.get().strip()
if copyuser_input:
try:
from src.erp_uploader import get_stuff_list, match_names_to_ids, setup_logger
logger = setup_logger()
stuff_list = get_stuff_list(logger)
if stuff_list:
names = [n.strip() for n in copyuser_input.split(',')]
copyuser_ids = match_names_to_ids(names, stuff_list, logger)
if not copyuser_ids:
copyuser_ids = []
else:
copyuser_ids = []
except Exception:
copyuser_ids = []
result[0] = True
result[1] = testing_id
result[2] = copyuser_ids
dialog.destroy()
def on_no():
"""取消上传"""
result[0] = False
result[1] = None
result[2] = None
dialog.destroy()
tk.Button(
......@@ -655,7 +750,7 @@ class ReportGeneratorGUI:
# 等待对话框关闭
dialog.wait_window()
return result[0]
return result[0], result[1], result[2]
def _show_info_dialog(self, title: str, message: str):
"""
......@@ -745,13 +840,14 @@ class ReportGeneratorGUI:
# 等待对话框关闭
dialog.wait_window()
def _upload_to_erp(self, report_path: str, developtesting_id: int = None):
def _upload_to_erp(self, report_path: str, developtesting_id: int = None, copyuser_list: list = None):
"""
上传报告到ERP
Args:
report_path: 报告文件路径
developtesting_id: 测试单ID(可选,默认使用config中的配置)
copyuser_list: 抄送人ID列表(可选,默认为空列表)
"""
try:
from src.erp_uploader import upload_report_to_erp, setup_logger
......@@ -765,7 +861,7 @@ class ReportGeneratorGUI:
# 在后台线程中上传
thread = threading.Thread(
target=self._upload_to_erp_thread,
args=(report_path, logger, developtesting_id),
args=(report_path, logger, developtesting_id, copyuser_list),
daemon=True
)
thread.start()
......@@ -777,7 +873,7 @@ class ReportGeneratorGUI:
self._log(f"[X] ERP上传功能异常: {str(e)}", "error")
self._show_error_dialog("错误", f"ERP上传功能异常:\n{str(e)}")
def _upload_to_erp_thread(self, report_path: str, logger, developtesting_id: int = None):
def _upload_to_erp_thread(self, report_path: str, logger, developtesting_id: int = None, copyuser_list: list = None):
"""
在后台线程中上传报告到ERP
......@@ -785,9 +881,10 @@ class ReportGeneratorGUI:
report_path: 报告文件路径
logger: 日志记录器
developtesting_id: 测试单ID(可选,默认使用config中的配置)
copyuser_list: 抄送人ID列表(可选,默认为空列表)
"""
try:
success = upload_report_to_erp(report_path, logger, developtesting_id=developtesting_id)
success = upload_report_to_erp(report_path, logger, developtesting_id=developtesting_id, copyuser_list=copyuser_list)
if success:
self._log("[OK] 报告已成功上传到ERP", "success")
......
......@@ -278,7 +278,7 @@ $Global:OldPlatformUpythonLogs = @(
$ModulePath = Join-Path $global:SCRIPT_DIR "modules"
# 强制卸载已加载的模块(确保使用最新版本)
$ModulesToUnload = @("ServiceCheck", "Common", "DNSCheck", "ServerResourceAnalysis", "NTPCheck", "ContainerCheck", "ConfigIPCheck", "MiddlewareCheck", "AndroidCheck", "LogExport", "Report", "ServerProfile", "DataBackup", "FilePermission", "ShellAdapter")
$ModulesToUnload = @("ServiceCheck", "Common", "DNSCheck", "ServerResourceAnalysis", "NTPCheck", "ContainerCheck", "ConfigIPCheck", "MiddlewareCheck", "AndroidCheck", "LogExport", "Report", "ServerProfile", "DataBackup", "FilePermission", "ShellAdapter", "SecurityCheck")
foreach ($ModuleName in $ModulesToUnload) {
if (Get-Module -Name $ModuleName -ErrorAction SilentlyContinue) {
Remove-Module -Name $ModuleName -Force -ErrorAction SilentlyContinue
......@@ -307,6 +307,7 @@ $ModulesToImport = @(
"FilePermission.psm1",
"ShellAdapter.psm1",
"ServiceCheck.psm1",
"SecurityCheck.psm1",
"DNSCheck.psm1",
"ServerResourceAnalysis.psm1",
"NTPCheck.psm1",
......@@ -381,6 +382,10 @@ $ExpectedFunctions = @(
"Test-RedisConnection",
"Test-MySQLConnection",
"Test-FastDFSConnection",
"Test-MySQLDeepCheck",
"Test-RedisDeepCheck",
# SecurityCheck 模块
"Test-SecurityCheck",
# ConfigIPCheck 模块
"Test-NewPlatformIPs",
"Test-TraditionalPlatformIPs",
......@@ -636,6 +641,18 @@ function Main {
# 中间件连接检测 (PRD 4.18)
Write-Host ""
# 安全合规检测 (PRD 2.1)
Write-Host ""
Write-Log -Level "INFO" -Message "========== 开始安全合规检测 =========="
$securityResults = @()
$secFunc = Get-Command Test-SecurityCheck -ErrorAction SilentlyContinue
if ($secFunc) {
$securityResults = & $secFunc -Server $server -PlatformType $platformType
} else {
Write-Log -Level "WARN" -Message "[安全] SecurityCheck 模块未加载,跳过安全合规检测"
}
Write-Log -Level "INFO" -Message "========== 安全合规检测完成 =========="
Write-Log -Level "INFO" -Message "========== 开始中间件连接检测 =========="
$middlewareResults = @()
......@@ -693,6 +710,29 @@ function Main {
Write-Log -Level "INFO" -Message "========== 中间件连接检测完成 =========="
# MySQL 深度检测 (PRD 2.2)
Write-Host ""
Write-Log -Level "INFO" -Message "========== 开始 MySQL 深度检测 =========="
$mysqlDeepResults = @()
$mysqlDeepFunc = Get-Command Test-MySQLDeepCheck -ErrorAction SilentlyContinue
if ($mysqlDeepFunc) {
$mysqlDeepResults = & $mysqlDeepFunc -Server $server
} else {
Write-Log -Level "WARN" -Message "[MySQL深度] Test-MySQLDeepCheck 函数未加载,跳过"
}
# Redis 深度检测 (PRD 2.3)
Write-Host ""
Write-Log -Level "INFO" -Message "========== 开始 Redis 深度检测 =========="
$redisDeepResults = @()
$redisDeepFunc = Get-Command Test-RedisDeepCheck -ErrorAction SilentlyContinue
if ($redisDeepFunc) {
$redisDeepResults = & $redisDeepFunc -Server $server
} else {
Write-Log -Level "WARN" -Message "[Redis深度] Test-RedisDeepCheck 函数未加载,跳过"
}
Write-Log -Level "INFO" -Message "========== 中间件深度检测完成 =========="
# 检测配置文件中的IP地址
Write-Host ""
Write-Log -Level "INFO" -Message "========== 开始检测配置文件 IP =========="
......@@ -824,7 +864,10 @@ function Main {
-ConsoleResults $consoleResults `
-ContainerInfo $containerInfo `
-AndroidResults $androidResults `
-MiddlewareResults $middlewareResults
-MiddlewareResults $middlewareResults `
-SecurityResults $securityResults `
-MySQLDeepResults $mysqlDeepResults `
-RedisDeepResults $redisDeepResults
}
# 执行主函数
......
......@@ -707,6 +707,84 @@ test_resources() {
raw+="防火墙状态: ${fw_active} (${fw_type})\n"
raw+="开放端口/服务: ${fw_open}\n"
report_kv_set "res.raw" "$(printf "%b" "$raw")"
# --- PRD 2.5 增强检测 ---
# inode 使用率检测
log INFO " inode 使用情况:"
local inode_lines=""
if command_exists df; then
inode_lines="$(df -i 2>/dev/null | awk 'NR==1{next} $1 ~ "^/dev/" && $1 !~ "tmpfs|overlay" {printf "%s|%s|%s|%s|%s\n",$6,$2,$3,$4,$5}' | head -n 20 || true)"
if [[ -n "$inode_lines" ]]; then
while IFS="|" read -r m t u f p; do
[[ -z "$m" ]] && continue
local ipct="${p%\%}"
local ist="正常"
[[ "${ipct:-0}" -ge 70 ]] && ist="警告"
[[ "${ipct:-0}" -ge 90 ]] && ist="异常"
log INFO " $m: inode ${u}/${t} (${p}) [$ist]"
done <<< "$inode_lines"
report_kv_set "res.inode" "$inode_lines"
else
report_kv_set "res.inode" "N/A"
fi
fi
# 只读挂载检测
local ro_mounts
ro_mounts="$(mount | grep ' ro[, ]' | grep -vE 'proc|sys|dev|cgroup|tmpfs' || true)"
if [[ -n "$ro_mounts" ]]; then
log WARN " 发现只读挂载:"
echo "$ro_mounts" | while IFS= read -r l; do log WARN " $l"; done
report_kv_set "res.ro_mounts" "$ro_mounts"
else
log SUCCESS " 无意外只读挂载"
report_kv_set "res.ro_mounts" "无"
fi
# TCP 状态分布
local tcp_stats
tcp_stats="$(netstat -ant 2>/dev/null | awk '{print $6}' | sort | uniq -c | sort -rn || true)"
if [[ -n "$tcp_stats" ]]; then
local close_wait=0 time_wait=0
close_wait="$(echo "$tcp_stats" | grep 'CLOSE_WAIT' | awk '{print $1}' || echo 0)"
time_wait="$(echo "$tcp_stats" | grep 'TIME_WAIT' | awk '{print $1}' || echo 0)"
close_wait="${close_wait:-0}"
time_wait="${time_wait:-0}"
local tcp_warn=""
[[ "$close_wait" -gt 100 ]] && tcp_warn="CLOSE_WAIT=$close_wait(>100) "
[[ "$time_wait" -gt 1000 ]] && tcp_warn="${tcp_warn}TIME_WAIT=$time_wait(>1000) "
if [[ -n "$tcp_warn" ]]; then
log WARN " TCP状态异常: $tcp_warn"
else
log SUCCESS " TCP状态正常: CLOSE_WAIT=$close_wait TIME_WAIT=$time_wait"
fi
report_kv_set "res.tcp_stats" "$tcp_stats"
report_kv_set "res.tcp_close_wait" "$close_wait"
report_kv_set "res.tcp_time_wait" "$time_wait"
fi
# 僵尸进程
local zombie_count
zombie_count="$(ps aux | awk '$8~/Z/' | grep -v grep | wc -l || echo 0)"
if [[ "$zombie_count" -gt 0 ]]; then
log WARN " 发现 $zombie_count 个僵尸进程"
else
log SUCCESS " 无僵尸进程"
fi
report_kv_set "res.zombie_count" "$zombie_count"
# TOP5 进程(按内存)
local top5
top5="$(ps aux --sort=-%mem | head -n 6 | awk '{printf "%s|%s|%s|%s|%s\n",$1,$2,$3,$4,$11}' || true)"
if [[ -n "$top5" ]]; then
log INFO " TOP5 进程(按内存):"
while IFS="|" read -r u p c m cmd; do
[[ -z "$u" || "$u" == "USER" ]] && continue
log INFO " $u PID=$p CPU=${c}% MEM=${m}% $cmd"
done <<< "$top5"
report_kv_set "res.top5" "$top5"
fi
}
# ------------------------------
......@@ -2282,6 +2360,257 @@ test_mysql_connection() {
return 0
}
# ------------------------------
# 14.2) MySQL 深度检测 (PRD 2.2)
# ------------------------------
test_mysql_deep() {
section "MySQL深度检测"
local mysql_container=""
mysql_container="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -E 'umysql|mysql' | head -n 1 || true)"
if [[ -z "$mysql_container" ]]; then
log INFO "[MySQL深度] 未检测到MySQL容器,跳过深度检测"
return 0
fi
local mysql_password="${MIDDLEWARE_MYSQL_PASSWORD:-dNrprU&2S}"
local mysql_cli="docker exec $mysql_container mysql -uroot -p\"${mysql_password}\" -N -e"
# 1. 缓冲池命中率
local bp_output
bp_output="$($mysql_cli "SHOW STATUS LIKE 'Innodb_buffer_pool_read%'" 2>/dev/null || true)"
if [[ -n "$bp_output" ]]; then
local read_requests=0 read_disk=0
read_requests="$(echo "$bp_output" | grep 'Innodb_buffer_pool_read_requests' | awk '{print $2}' || echo 0)"
read_disk="$(echo "$bp_output" | grep 'Innodb_buffer_pool_reads' | awk '{print $2}' || echo 0)"
local hit_rate=0
if [[ "$read_requests" -gt 0 ]]; then
hit_rate="$(awk "BEGIN{printf \"%.2f\", ($read_requests - $read_disk) / $read_requests * 100}")"
fi
local bp_status="正常"
awk "BEGIN{exit !($hit_rate < 95)}" && bp_status="警告"
awk "BEGIN{exit !($hit_rate < 80)}" && bp_status="异常"
report_kv_set "mysql_deep.bp_rate" "${hit_rate}%"
report_kv_set "mysql_deep.bp_status" "$bp_status"
log INFO "[MySQL深度] 缓冲池命中率: ${hit_rate}% [$bp_status]"
fi
# 2. 慢查询状态
local slow_output
slow_output="$($mysql_cli "SHOW VARIABLES LIKE 'slow_query_log'; SHOW STATUS LIKE 'Slow_queries'" 2>/dev/null || true)"
if [[ -n "$slow_output" ]]; then
local slow_enabled slow_count
slow_enabled="$(echo "$slow_output" | grep 'slow_query_log' | awk '{print $2}' || echo "UNKNOWN")"
slow_count="$(echo "$slow_output" | grep 'Slow_queries' | awk '{print $2}' || echo 0)"
local slow_status="正常"
[[ "$slow_enabled" != "ON" ]] && slow_status="警告"
report_kv_set "mysql_deep.slow_enabled" "$slow_enabled"
report_kv_set "mysql_deep.slow_count" "$slow_count"
report_kv_set "mysql_deep.slow_status" "$slow_status"
log INFO "[MySQL深度] 慢查询日志: $slow_enabled | 慢查询数: $slow_count [$slow_status]"
fi
# 3. 连接使用率
local conn_output
conn_output="$($mysql_cli "SHOW STATUS LIKE 'Threads_connected'; SHOW VARIABLES LIKE 'max_connections'" 2>/dev/null || true)"
if [[ -n "$conn_output" ]]; then
local current_conn=0 max_conn=0
current_conn="$(echo "$conn_output" | grep 'Threads_connected' | awk '{print $2}' || echo 0)"
max_conn="$(echo "$conn_output" | grep 'max_connections' | awk '{print $2}' || echo 0)"
local conn_rate=0
if [[ "$max_conn" -gt 0 ]]; then
conn_rate="$(awk "BEGIN{printf \"%.1f\", $current_conn / $max_conn * 100}")"
fi
local conn_status="正常"
awk "BEGIN{exit !($conn_rate >= 80)}" && conn_status="警告"
report_kv_set "mysql_deep.conn_rate" "${conn_rate}%"
report_kv_set "mysql_deep.conn_status" "$conn_status"
log INFO "[MySQL深度] 连接使用率: ${conn_rate}% ($current_conn/$max_conn) [$conn_status]"
fi
# 4. 主从复制状态
local repl_output
repl_output="$($mysql_cli 'SHOW SLAVE STATUS\G' 2>/dev/null || true)"
if [[ -n "$repl_output" ]]; then
local io_running="" sql_running="" seconds_behind=""
io_running="$(echo "$repl_output" | grep 'Slave_IO_Running:' | awk '{print $2}' || true)"
sql_running="$(echo "$repl_output" | grep 'Slave_SQL_Running:' | awk '{print $2}' || true)"
seconds_behind="$(echo "$repl_output" | grep 'Seconds_Behind_Master:' | awk '{print $2}' || true)"
if [[ -n "$io_running" ]]; then
local repl_status="正常"
[[ "$io_running" != "Yes" || "$sql_running" != "Yes" ]] && repl_status="异常"
report_kv_set "mysql_deep.repl_status" "$repl_status"
report_kv_set "mysql_deep.repl_detail" "IO=$io_running SQL=$sql_running 延迟=${seconds_behind}s"
log INFO "[MySQL深度] 主从复制: IO=$io_running SQL=$sql_running 延迟=${seconds_behind}s [$repl_status]"
else
report_kv_set "mysql_deep.repl_status" "正常"
report_kv_set "mysql_deep.repl_detail" "未配置主从复制(单机模式)"
log INFO "[MySQL深度] 未配置主从复制(单机模式)"
fi
fi
# 5. TOP20 大表
local top_output
top_output="$($mysql_cli "SELECT table_name, ROUND(data_length/1024/1024,2) AS data_mb, table_rows FROM information_schema.TABLES WHERE table_schema NOT IN ('mysql','information_schema','performance_schema','sys') ORDER BY data_length DESC LIMIT 20" 2>/dev/null || true)"
if [[ -n "$top_output" ]]; then
local table_count
table_count="$(echo "$top_output" | wc -l)"
report_kv_set "mysql_deep.top_tables" "$table_count"
log INFO "[MySQL深度] TOP20大表: $table_count 个表"
fi
# 6. QPS 性能基线
local qps_output
qps_output="$($mysql_cli "SHOW STATUS LIKE 'Queries'; SHOW STATUS LIKE 'Uptime'" 2>/dev/null || true)"
if [[ -n "$qps_output" ]]; then
local queries=0 uptime=0
queries="$(echo "$qps_output" | grep 'Queries' | awk '{print $2}' || echo 0)"
uptime="$(echo "$qps_output" | grep 'Uptime' | awk '{print $2}' || echo 0)"
local qps=0
if [[ "$uptime" -gt 0 ]]; then
qps="$(awk "BEGIN{printf \"%.2f\", $queries / $uptime}")"
fi
report_kv_set "mysql_deep.qps" "$qps"
log INFO "[MySQL深度] QPS: $qps | 总查询: $queries | 运行时间: ${uptime}s"
fi
# 报告摘要
report_add ""
report_add "### MySQL深度检测"
report_add "- 缓冲池命中率: $(report_kv_get "mysql_deep.bp_rate") [$(report_kv_get "mysql_deep.bp_status")]"
report_add "- 慢查询: $(report_kv_get "mysql_deep.slow_enabled") (count=$(report_kv_get "mysql_deep.slow_count")) [$(report_kv_get "mysql_deep.slow_status")]"
report_add "- 连接使用率: $(report_kv_get "mysql_deep.conn_rate") [$(report_kv_get "mysql_deep.conn_status")]"
report_add "- 主从复制: $(report_kv_get "mysql_deep.repl_detail") [$(report_kv_get "mysql_deep.repl_status")]"
report_add "- TOP20大表: $(report_kv_get "mysql_deep.top_tables") 个"
report_add "- QPS: $(report_kv_get "mysql_deep.qps")"
}
# ------------------------------
# 14.3) Redis 深度检测 (PRD 2.3)
# ------------------------------
test_redis_deep() {
section "Redis深度检测"
local redis_container=""
redis_container="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -E 'uredis|redis' | head -n 1 || true)"
if [[ -z "$redis_container" ]]; then
log INFO "[Redis深度] 未检测到Redis容器,跳过深度检测"
return 0
fi
local redis_password="${MIDDLEWARE_REDIS_PASSWORD:-dNrprU&2S}"
local redis_cli="docker exec $redis_container redis-cli -a \"$redis_password\" --no-auth-warning"
# 1. RDB 持久化状态
local rdb_output
rdb_output="$($redis_cli INFO Persistence 2>/dev/null || true)"
if [[ -n "$rdb_output" ]]; then
local rdb_status rdb_last_save
rdb_status="$(echo "$rdb_output" | grep 'rdb_last_bgsave_status:' | cut -d: -f2 | tr -d '\r' || true)"
rdb_last_save="$(echo "$rdb_output" | grep 'rdb_last_save_time:' | cut -d: -f2 | tr -d '\r' || true)"
local rdb_check="正常"
[[ "$rdb_status" != "ok" ]] && rdb_check="异常"
report_kv_set "redis_deep.rdb_status" "$rdb_check"
report_kv_set "redis_deep.rdb_detail" "bgsave=$rdb_status 最后保存=${rdb_last_save}"
log INFO "[Redis深度] RDB持久化: $rdb_status [$rdb_check]"
fi
# 2. AOF 持久化状态
if [[ -n "$rdb_output" ]]; then
local aof_enabled aof_write_status
aof_enabled="$(echo "$rdb_output" | grep 'aof_enabled:' | cut -d: -f2 | tr -d '\r' || echo "0")"
aof_write_status="$(echo "$rdb_output" | grep 'aof_last_write_status:' | cut -d: -f2 | tr -d '\r' || true)"
local aof_check="正常"
if [[ "$aof_enabled" == "1" && "$aof_write_status" != "ok" ]]; then
aof_check="异常"
elif [[ "$aof_enabled" != "1" ]]; then
aof_check="建议"
fi
local aof_label="未启用"
[[ "$aof_enabled" == "1" ]] && aof_label="已启用"
report_kv_set "redis_deep.aof_status" "$aof_check"
report_kv_set "redis_deep.aof_detail" "AOF=$aof_label 写入=$aof_write_status"
log INFO "[Redis深度] AOF持久化: $aof_label | 写入: $aof_write_status [$aof_check]"
fi
# 3. 内存碎片率
local mem_output
mem_output="$($redis_cli INFO Memory 2>/dev/null || true)"
if [[ -n "$mem_output" ]]; then
local frag_ratio
frag_ratio="$(echo "$mem_output" | grep 'mem_fragmentation_ratio:' | cut -d: -f2 | tr -d '\r' || echo "1.0")"
local frag_status="正常"
awk "BEGIN{exit !($frag_ratio >= 1.5)}" && frag_status="警告"
awk "BEGIN{exit !($frag_ratio >= 2.0)}" && frag_status="异常"
report_kv_set "redis_deep.frag_ratio" "$frag_ratio"
report_kv_set "redis_deep.frag_status" "$frag_status"
log INFO "[Redis深度] 内存碎片率: ${frag_ratio} [$frag_status]"
fi
# 4. 缓存命中率
local stats_output
stats_output="$($redis_cli INFO Stats 2>/dev/null || true)"
if [[ -n "$stats_output" ]]; then
local hits=0 misses=0
hits="$(echo "$stats_output" | grep 'keyspace_hits:' | cut -d: -f2 | tr -d '\r' || echo 0)"
misses="$(echo "$stats_output" | grep 'keyspace_misses:' | cut -d: -f2 | tr -d '\r' || echo 0)"
local hit_rate=0
if [[ "$((hits + misses))" -gt 0 ]]; then
hit_rate="$(awk "BEGIN{printf \"%.2f\", $hits / ($hits + $misses) * 100}")"
fi
local hit_status="正常"
awk "BEGIN{exit !($hit_rate < 80)}" && hit_status="警告"
awk "BEGIN{exit !($hit_rate < 50)}" && hit_status="异常"
report_kv_set "redis_deep.hit_rate" "${hit_rate}%"
report_kv_set "redis_deep.hit_status" "$hit_status"
log INFO "[Redis深度] 缓存命中率: ${hit_rate}% (命中: $hits / 未命中: $misses) [$hit_status]"
fi
# 5. 主从复制状态
local repl_output
repl_output="$($redis_cli INFO Replication 2>/dev/null || true)"
if [[ -n "$repl_output" ]]; then
local redis_role master_link
redis_role="$(echo "$repl_output" | grep '^role:' | cut -d: -f2 | tr -d '\r' || true)"
master_link="$(echo "$repl_output" | grep 'master_link_status:' | cut -d: -f2 | tr -d '\r' || true)"
local repl_status="正常"
local repl_detail="单机模式"
if [[ "$redis_role" == "master" ]]; then
repl_detail="master 节点"
elif [[ "$redis_role" == "slave" ]]; then
repl_detail="slave 节点 | master_link_status: $master_link"
[[ "$master_link" != "up" ]] && repl_status="异常"
fi
report_kv_set "redis_deep.repl_status" "$repl_status"
report_kv_set "redis_deep.repl_detail" "$repl_detail"
log INFO "[Redis深度] 主从复制: $repl_detail [$repl_status]"
fi
# 6. 键空间统计
local ks_output
ks_output="$($redis_cli INFO Keyspace 2>/dev/null || true)"
if [[ -n "$ks_output" ]]; then
local ks_count
ks_count="$(echo "$ks_output" | grep -c '^db[0-9]' || echo 0)"
report_kv_set "redis_deep.ks_count" "$ks_count"
log INFO "[Redis深度] 键空间: $ks_count 个数据库"
fi
# 报告摘要
report_add ""
report_add "### Redis深度检测"
report_add "- RDB持久化: $(report_kv_get "redis_deep.rdb_detail") [$(report_kv_get "redis_deep.rdb_status")]"
report_add "- AOF持久化: $(report_kv_get "redis_deep.aof_detail") [$(report_kv_get "redis_deep.aof_status")]"
report_add "- 内存碎片率: $(report_kv_get "redis_deep.frag_ratio") [$(report_kv_get "redis_deep.frag_status")]"
report_add "- 缓存命中率: $(report_kv_get "redis_deep.hit_rate") [$(report_kv_get "redis_deep.hit_status")]"
report_add "- 主从复制: $(report_kv_get "redis_deep.repl_detail") [$(report_kv_get "redis_deep.repl_status")]"
report_add "- 键空间: $(report_kv_get "redis_deep.ks_count") 个数据库"
}
# FastDFS 连接检测
test_fastdfs_connection() {
section "FastDFS连接检测"
......@@ -2496,6 +2825,381 @@ test_fastdfs_connection() {
return 0
}
# ------------------------------
# 16.1) 安全合规检测:弱密码检测(PRD 2.1)
# ------------------------------
# 弱密码字典
WEAK_PASSWORD_DICT=("123456" "password" "root" "admin" "redis" "mysql" "test" "guest")
# SUID 白名单
SUID_WHITELIST=(
"/usr/bin/sudo" "/usr/bin/passwd" "/usr/bin/su" "/usr/bin/ping"
"/usr/bin/mount" "/usr/bin/umount" "/usr/bin/newgrp" "/usr/bin/chsh"
"/usr/bin/chfn" "/usr/bin/gpasswd" "/usr/lib/openssh/ssh-keysign"
)
# 端口白名单
NEW_PLATFORM_PORT_WHITELIST=(22 80 443 1883 3306 6379 8083 8084 8888 18083)
OLD_PLATFORM_PORT_WHITELIST=(22 80 443 1883 3306 6379 8081 8082 8443 9001)
# MySQL 弱密码检测(Root访问范围 + 空密码用户)
check_mysql_weak_password() {
section "安全检测: MySQL弱密码"
local mysql_container=""
mysql_container="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -E 'umysql|mysql' | head -n 1 || true)"
if [[ -z "$mysql_container" ]]; then
log INFO "[安全] 未检测到MySQL容器,跳过MySQL弱密码检测"
report_kv_set "sec.mysql_root" "SKIP"
report_kv_set "sec.mysql_empty" "SKIP"
return 0
fi
local mysql_password="${MIDDLEWARE_MYSQL_PASSWORD:-dNrprU&2S}"
# MySQL Root 访问范围
local root_hosts
root_hosts="$(docker exec "$mysql_container" mysql -uroot -p"${mysql_password}" -N -e "SELECT host FROM mysql.user WHERE user='root'" 2>/dev/null | tr '\n' ',' | sed 's/,$//' || true)"
if [[ -n "$root_hosts" ]]; then
if echo "$root_hosts" | grep -q '%'; then
log WARN "[安全] MySQL Root 允许任意主机访问(root@%)"
report_kv_set "sec.mysql_root" "DANGER"
report_kv_set "sec.mysql_root_detail" "root@% 允许任意主机访问 | 当前host: $root_hosts"
else
log SUCCESS "[安全] MySQL Root 仅允许本地访问 ($root_hosts)"
report_kv_set "sec.mysql_root" "OK"
report_kv_set "sec.mysql_root_detail" "root 仅允许 $root_hosts 访问"
fi
else
report_kv_set "sec.mysql_root" "SKIP"
report_kv_set "sec.mysql_root_detail" "无法查询(密码错误或权限不足)"
fi
# MySQL 空密码用户
local empty_users
empty_users="$(docker exec "$mysql_container" mysql -uroot -p"${mysql_password}" -N -e "SELECT user,host FROM mysql.user WHERE authentication_string=''" 2>/dev/null || true)"
if [[ -n "$empty_users" ]]; then
local empty_count
empty_count="$(echo "$empty_users" | wc -l)"
log WARN "[安全] MySQL 存在 $empty_count 个空密码用户"
report_kv_set "sec.mysql_empty" "DANGER"
report_kv_set "sec.mysql_empty_detail" "发现 $empty_count 个空密码用户"
else
log SUCCESS "[安全] MySQL 无空密码用户"
report_kv_set "sec.mysql_empty" "OK"
report_kv_set "sec.mysql_empty_detail" "无空密码用户"
fi
}
# Redis 弱密码检测
check_redis_weak_password() {
log INFO "[安全] Redis弱密码检测..."
local redis_container=""
redis_container="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -E 'uredis|redis' | head -n 1 || true)"
if [[ -z "$redis_container" ]]; then
log INFO "[安全] 未检测到Redis容器,跳过Redis弱密码检测"
report_kv_set "sec.redis_pwd" "SKIP"
return 0
fi
local requirepass
requirepass="$(docker exec "$redis_container" sh -c "grep -E '^requirepass' /etc/redis/redis.conf 2>/dev/null || grep -E '^requirepass' /usr/local/etc/redis/redis.conf 2>/dev/null || echo 'NO_CONFIG'" | head -n 1 || true)"
if [[ "$requirepass" == "NO_CONFIG" || -z "$requirepass" ]]; then
log WARN "[安全] Redis 未设置密码(requirepass 为空)"
report_kv_set "sec.redis_pwd" "DANGER"
report_kv_set "sec.redis_pwd_detail" "requirepass 未设置,Redis 无密码保护"
else
local pwd_value
pwd_value="$(echo "$requirepass" | awk '{print $2}')"
local is_weak=0
for wp in "${WEAK_PASSWORD_DICT[@]}"; do
if [[ "$pwd_value" == "$wp" ]]; then
is_weak=1; break
fi
done
if [[ $is_weak -eq 1 ]]; then
log WARN "[安全] Redis 使用弱密码"
report_kv_set "sec.redis_pwd" "DANGER"
report_kv_set "sec.redis_pwd_detail" "Redis 密码为弱密码"
else
log SUCCESS "[安全] Redis 密码强度正常"
report_kv_set "sec.redis_pwd" "OK"
report_kv_set "sec.redis_pwd_detail" "requirepass 已设置且非弱密码"
fi
fi
}
# EMQX 默认密码检测
check_emqx_weak_password() {
log INFO "[安全] EMQX弱密码检测..."
local emqx_container=""
emqx_container="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -E 'uemqx|emqx' | head -n 1 || true)"
if [[ -z "$emqx_container" ]]; then
log INFO "[安全] 未检测到EMQX容器,跳过EMQX弱密码检测"
report_kv_set "sec.emqx_pwd" "SKIP"
return 0
fi
local dashboard_port="${MIDDLEWARE_EMQX_DASHBOARD_PORT:-18083}"
local http_code
http_code="$(docker exec "$emqx_container" curl -s -o /dev/null -w '%{http_code}' -u admin:public "http://localhost:${dashboard_port}/api/v5/status" 2>/dev/null || echo 'FAIL')"
if [[ "$http_code" =~ ^(200|204)$ ]]; then
log WARN "[安全] EMQX 使用默认密码(admin/public)"
report_kv_set "sec.emqx_pwd" "DANGER"
report_kv_set "sec.emqx_pwd_detail" "EMQX Dashboard 使用默认密码 admin/public"
else
log SUCCESS "[安全] EMQX 未使用默认密码"
report_kv_set "sec.emqx_pwd" "OK"
report_kv_set "sec.emqx_pwd_detail" "EMQX Dashboard 未使用默认密码(认证返回 $http_code)"
fi
}
# Linux 空密码账户检测
check_linux_weak_password() {
log INFO "[安全] Linux空密码账户检测..."
if [[ ! -r /etc/shadow ]]; then
report_kv_set "sec.linux_empty" "SKIP"
report_kv_set "sec.linux_empty_detail" "无法读取 /etc/shadow"
return 0
fi
local empty_users
empty_users="$(awk -F: '$2 == "" {print $1}' /etc/shadow 2>/dev/null || true)"
if [[ -n "$empty_users" ]]; then
local empty_count
empty_count="$(echo "$empty_users" | wc -l)"
log WARN "[安全] 发现 $empty_count 个空密码账户"
report_kv_set "sec.linux_empty" "DANGER"
report_kv_set "sec.linux_empty_detail" "发现 $empty_count 个空密码账户: $(echo $empty_users | tr '\n' ',')"
else
log SUCCESS "[安全] 无Linux空密码账户"
report_kv_set "sec.linux_empty" "OK"
report_kv_set "sec.linux_empty_detail" "无空密码账户"
fi
}
# SUID 文件检测
check_suid_files() {
log INFO "[安全] 可疑SUID文件检测..."
local suid_files
# 排除 Docker overlay/containerd 文件系统,避免容器层 SUID 误报
suid_files="$(find / -perm -4000 -type f -not -path '/data/dockers/*' -not -path '/var/lib/docker/*' -not -path '/proc/*' -not -path '/sys/*' 2>/dev/null | sort || true)"
if [[ -z "$suid_files" ]]; then
report_kv_set "sec.suid" "OK"
report_kv_set "sec.suid_detail" "无SUID文件"
return 0
fi
local suspicious=""
while IFS= read -r f; do
local is_wl=0
for wl in "${SUID_WHITELIST[@]}"; do
if [[ "$f" == "$wl" ]]; then is_wl=1; break; fi
done
if [[ $is_wl -eq 0 ]]; then
suspicious="${suspicious}${f} "
fi
done <<< "$suid_files"
if [[ -n "$suspicious" ]]; then
local sus_count
sus_count="$(echo "$suspicious" | wc -w)"
log WARN "[安全] 发现 $sus_count 个白名单外SUID文件"
report_kv_set "sec.suid" "WARN"
report_kv_set "sec.suid_detail" "发现 $sus_count 个白名单外SUID文件"
else
local total_count
total_count="$(echo "$suid_files" | wc -l)"
log SUCCESS "[安全] 所有SUID文件均在白名单中(共 $total_count 个)"
report_kv_set "sec.suid" "OK"
report_kv_set "sec.suid_detail" "所有 $total_count 个SUID文件均在白名单中"
fi
}
# 异常 crontab 检测
check_crontab() {
log INFO "[安全] 异常crontab检测..."
local all_cron=""
# 用户crontab
for user in $(cut -d: -f1 /etc/passwd 2>/dev/null); do
local uc
uc="$(crontab -u "$user" -l 2>/dev/null | grep -vE '^\s*#|^\s*$' || true)"
if [[ -n "$uc" ]]; then
all_cron="${all_cron}[用户:$user] ${uc}\n"
fi
done
# 系统 crontab
all_cron="${all_cron}$(grep -vE '^\s*#|^\s*$' /etc/crontab 2>/dev/null || true)\n"
# cron.d
for f in /etc/cron.d/*; do
[[ -f "$f" ]] && all_cron="${all_cron}[$(basename $f)] $(grep -vE '^\s*#|^\s*$' "$f" 2>/dev/null || true)\n"
done
if [[ -z "$all_cron" || "$all_cron" == *$'\n' ]]; then
report_kv_set "sec.crontab" "OK"
report_kv_set "sec.crontab_detail" "无定时任务"
return 0
fi
# 已知合法定时任务关键词
local known="health_check|check_server|backup|logrotate|certbot|updatedb|man-db|mlocate|sysstat|docker|ntp|repair|issue_handler|clean|tmpwatch"
local suspicious=""
while IFS= read -r line; do
[[ -z "$line" ]] && continue
[[ "$line" =~ ^\[ ]] && continue
if ! echo "$line" | grep -qE "$known"; then
if echo "$line" | grep -q '/'; then
suspicious="${suspicious}${line} | "
fi
fi
done <<< "$(printf '%b' "$all_cron")"
if [[ -n "$suspicious" ]]; then
local sus_count
sus_count="$(echo "$suspicious" | grep -c '|' || true)"
log WARN "[安全] 发现 $sus_count 条可疑定时任务"
report_kv_set "sec.crontab" "WARN"
report_kv_set "sec.crontab_detail" "发现 $sus_count 条可疑定时任务(需人工确认)"
else
log SUCCESS "[安全] 所有定时任务均为已知任务"
report_kv_set "sec.crontab" "OK"
report_kv_set "sec.crontab_detail" "所有定时任务均为已知任务"
fi
}
# SSH 暴力破解检测
check_ssh_bruteforce() {
log INFO "[安全] SSH暴力破解检测..."
local logfile=""
if [[ -f /var/log/secure ]]; then
logfile="/var/log/secure"
elif [[ -f /var/log/auth.log ]]; then
logfile="/var/log/auth.log"
fi
if [[ -z "$logfile" ]]; then
report_kv_set "sec.ssh_bf" "SKIP"
report_kv_set "sec.ssh_bf_detail" "未找到SSH日志文件"
return 0
fi
local bf_ips=""
while IFS= read -r line; do
local count ip
count="$(echo "$line" | awk '{print $1}')"
ip="$(echo "$line" | awk '{print $2}')"
if [[ "$count" -ge 50 ]]; then
bf_ips="${bf_ips}${ip}(${count}次) "
fi
done < <(grep -i "failed\|failure\|invalid" "$logfile" 2>/dev/null | grep -oE 'from [0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | awk '{print $2}' | sort | uniq -c | sort -rn | head -n 10)
if [[ -n "$bf_ips" ]]; then
log WARN "[安全] 检测到SSH暴力破解IP: $bf_ips"
report_kv_set "sec.ssh_bf" "DANGER"
report_kv_set "sec.ssh_bf_detail" "发现暴力破解IP: $bf_ips"
else
log SUCCESS "[安全] 未检测到SSH暴力破解"
report_kv_set "sec.ssh_bf" "OK"
report_kv_set "sec.ssh_bf_detail" "未检测到暴力破解(无IP超过50次阈值)"
fi
}
# 异常端口检测
check_open_ports() {
log INFO "[安全] 异常端口检测..."
local platform="$1"
local -a whitelist
if [[ "$platform" == "new" ]]; then
whitelist=("${NEW_PLATFORM_PORT_WHITELIST[@]}")
else
whitelist=("${OLD_PLATFORM_PORT_WHITELIST[@]}")
fi
local listening_ports
listening_ports="$(netstat -tlnp 2>/dev/null | grep LISTEN | awk '{print $4}' | grep -oE '[0-9]+$' | sort -u || true)"
if [[ -z "$listening_ports" ]]; then
report_kv_set "sec.ports" "SKIP"
report_kv_set "sec.ports_detail" "无法获取监听端口列表"
return 0
fi
local unexpected=""
while IFS= read -r port; do
[[ -z "$port" ]] && continue
local is_wl=0
for wp in "${whitelist[@]}"; do
if [[ "$port" == "$wp" ]]; then is_wl=1; break; fi
done
if [[ $is_wl -eq 0 ]]; then
unexpected="${unexpected}${port},"
fi
done <<< "$listening_ports"
if [[ -n "$unexpected" ]]; then
local unx_count
unx_count="$(echo "$unexpected" | tr ',' '\n' | grep -c '[0-9]' || true)"
log WARN "[安全] 发现 $unx_count 个非预期监听端口: $unexpected"
report_kv_set "sec.ports" "WARN"
report_kv_set "sec.ports_detail" "发现 $unx_count 个非预期监听端口: $unexpected"
else
local total_count
total_count="$(echo "$listening_ports" | wc -l)"
log SUCCESS "[安全] 所有 $total_count 个监听端口均在白名单中"
report_kv_set "sec.ports" "OK"
report_kv_set "sec.ports_detail" "所有 $total_count 个监听端口均在白名单中"
fi
}
# 安全合规检测总入口
test_security_check() {
local platform="$1"
section "安全合规检测"
# 弱密码检测
check_mysql_weak_password
check_redis_weak_password
check_emqx_weak_password
check_linux_weak_password
# 安全基线扫描
check_suid_files
check_crontab
check_ssh_bruteforce
check_open_ports "$platform"
# 报告摘要
report_add ""
report_add "## 安全合规检测"
report_add "- MySQL Root访问: $(report_kv_get "sec.mysql_root")"
report_add "- MySQL空密码: $(report_kv_get "sec.mysql_empty")"
report_add "- Redis密码: $(report_kv_get "sec.redis_pwd")"
report_add "- EMQX密码: $(report_kv_get "sec.emqx_pwd")"
report_add "- Linux空密码: $(report_kv_get "sec.linux_empty")"
report_add "- SUID文件: $(report_kv_get "sec.suid")"
report_add "- Crontab: $(report_kv_get "sec.crontab")"
report_add "- SSH暴力破解: $(report_kv_get "sec.ssh_bf")"
report_add "- 异常端口: $(report_kv_get "sec.ports")"
return 0
}
# 中间件连接检测统一入口
test_middleware_connections() {
section "中间件连接检测"
......@@ -2572,6 +3276,12 @@ test_middleware_connections() {
# MySQL连接检测
test_mysql_connection || true
# MySQL深度检测 (PRD 2.2)
test_mysql_deep || true
# Redis深度检测 (PRD 2.3)
test_redis_deep || true
# FastDFS连接检测
log INFO "[中间件] ========== 开始FastDFS连接检测 =========="
log INFO "[中间件] 传递给FastDFS函数的Storage日志路径: ${MiddlewareStorageLogPath:-<未设置>}"
......@@ -2661,11 +3371,11 @@ data_backup() {
copy_tree_if_exists "/var/www/redis" "var_www_redis" || true
copy_tree_if_exists "/var/www/emqx" "var_www_emqx" || true
else
# 新平台:按实际目录结构做一个合理对齐(可按需调整)
# 新平台:按实际目录结构备份
copy_tree_if_exists "/data/services" "data_services" || true
copy_tree_if_exists "/data/middleware" "data_middleware" || true
copy_tree_if_exists "/data/third_party" "data_third_party"|| true
copy_tree_if_exists "/etc" "etc" || true
# 注意:新平台不备份 /etc,避免备份过大
fi
# 数据库备份(如有 umysql 容器)
......@@ -3540,6 +4250,48 @@ write_report() {
w "- NTP: \`$(report_kv_get "ntp.status")\` (impl: $(report_kv_get "ntp.impl"), diff: $(report_kv_get "ntp.diff_seconds"))"
w "- Perm: miss_count=\`$(report_kv_get "perm.miss_count")\`"
w "- Docker: \`$(report_kv_get "docker.available")\`"
# 安全合规检测摘要
w ""
w "### 安全合规检测摘要"
w "- MySQL Root访问: \`$(report_kv_get "sec.mysql_root")\`"
w "- MySQL空密码: \`$(report_kv_get "sec.mysql_empty")\`"
w "- Redis密码: \`$(report_kv_get "sec.redis_pwd")\`"
w "- EMQX密码: \`$(report_kv_get "sec.emqx_pwd")\`"
w "- Linux空密码: \`$(report_kv_get "sec.linux_empty")\`"
w "- SUID文件: \`$(report_kv_get "sec.suid")\`"
w "- Crontab: \`$(report_kv_get "sec.crontab")\`"
w "- SSH暴力破解: \`$(report_kv_get "sec.ssh_bf")\`"
w "- 异常端口: \`$(report_kv_get "sec.ports")\`"
# MySQL 深度检测摘要
w ""
w "### MySQL 深度检测摘要"
w "- 缓冲池命中率: \`$(report_kv_get "mysql_deep.bp_rate")\` [$(report_kv_get "mysql_deep.bp_status")]"
w "- 慢查询: \`$(report_kv_get "mysql_deep.slow_enabled")\` (count=$(report_kv_get "mysql_deep.slow_count")) [$(report_kv_get "mysql_deep.slow_status")]"
w "- 连接使用率: \`$(report_kv_get "mysql_deep.conn_rate")\` [$(report_kv_get "mysql_deep.conn_status")]"
w "- 主从复制: \`$(report_kv_get "mysql_deep.repl_detail")\` [$(report_kv_get "mysql_deep.repl_status")]"
w "- TOP20大表: \`$(report_kv_get "mysql_deep.top_tables")\` 个"
w "- QPS: \`$(report_kv_get "mysql_deep.qps")\`"
# Redis 深度检测摘要
w ""
w "### Redis 深度检测摘要"
w "- RDB持久化: \`$(report_kv_get "redis_deep.rdb_detail")\` [$(report_kv_get "redis_deep.rdb_status")]"
w "- AOF持久化: \`$(report_kv_get "redis_deep.aof_detail")\` [$(report_kv_get "redis_deep.aof_status")]"
w "- 内存碎片率: \`$(report_kv_get "redis_deep.frag_ratio")\` [$(report_kv_get "redis_deep.frag_status")]"
w "- 缓存命中率: \`$(report_kv_get "redis_deep.hit_rate")\` [$(report_kv_get "redis_deep.hit_status")]"
w "- 主从复制: \`$(report_kv_get "redis_deep.repl_detail")\` [$(report_kv_get "redis_deep.repl_status")]"
w "- 键空间: \`$(report_kv_get "redis_deep.ks_count")\` 个数据库"
# 资源增强检测摘要
w ""
w "### 资源增强检测摘要"
w "- inode: 详见检测详情"
w "- 只读挂载: \`$(report_kv_get "res.ro_mounts")\`"
w "- TCP CLOSE_WAIT: \`$(report_kv_get "res.tcp_close_wait")\`"
w "- TCP TIME_WAIT: \`$(report_kv_get "res.tcp_time_wait")\`"
w "- 僵尸进程: \`$(report_kv_get "res.zombie_count")\` 个"
w ""
w "## 检测详情"
......@@ -3600,6 +4352,46 @@ write_report() {
w "| $m | $t | $u | $a | $p |"
done <<< "$disk"
fi
# inode 详情
local inode
inode="$(report_kv_get "res.inode")"
if [[ -n "$inode" && "$inode" != "N/A" ]]; then
w ""
w "| 挂载点 | inode总量 | inode已用 | inode可用 | 使用率 |"
w "|---|---:|---:|---:|---:|"
while IFS="|" read -r m t u f p; do
[[ -z "$m" ]] && continue
w "| $m | $t | $u | $f | $p |"
done <<< "$inode"
fi
w ""
# TCP 状态详情
local tcp_raw
tcp_raw="$(report_kv_get "res.tcp_stats")"
if [[ -n "$tcp_raw" ]]; then
w "### TCP 状态分布"
w '```'
printf "%s\n" "$tcp_raw" >> "$report_file"
w '```'
w ""
fi
# TOP5 进程
local top5
top5="$(report_kv_get "res.top5")"
if [[ -n "$top5" ]]; then
w "### TOP5 进程(按内存)"
w "| USER | PID | CPU% | MEM% | CMD |"
w "|---|---|---:|---:|---|"
while IFS="|" read -r u p c m cmd; do
[[ -z "$u" ]] && continue
[[ "$u" == "USER" ]] && continue
w "| $u | $p | $c | $m | $cmd |"
done <<< "$top5"
w ""
fi
w ""
# 防火墙:open 单行 + raw 代码块
......@@ -3967,6 +4759,9 @@ main() {
report_add "- exist_count: $(report_kv_get "perm.exist_count")"
report_add "- miss_count: $(report_kv_get "perm.miss_count")"
# 6.5) 安全合规检测 (PRD 2.1) - 在中间件检测之前执行
test_security_check "$platform"
# 7) 中间件连接检测 (PRD 4.18) - 调整至备份前执行
test_middleware_connections
......
# ==============================================================================
# DataBackup.psm1
# ------------------------------------------------------------------------------
# 远程文件下载与数据备份模块
#
# .SYNOPSIS
# 提供远程文件下载和数据备份功能
#
# .DESCRIPTION
# 本模块包含:
# - Download-RemoteFile: 通过 pscp 从远程服务器下载文件
# - DataBakup: 远程数据备份(MySQL dump + 打包 + 下载)
#
# .NOTES
# 版本:1.0.0
# 创建日期:2026-06-06
# 从 check_server_health.ps1 主脚本拆分而来
#
# ==============================================================================
# 导入公共模块
$ModuleDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$CommonModulePath = Join-Path $ModuleDir "Common.psm1"
if (Test-Path $CommonModulePath) {
Import-Module $CommonModulePath -Force -Global -ErrorAction SilentlyContinue
}
# ==============================================================================
# 从远端下载文件到本地(基于 pscp)
# ==============================================================================
function Download-RemoteFile {
<#
.SYNOPSIS
从远程服务器下载文件到本地
.DESCRIPTION
使用 pscp.exe 从远程服务器下载文件到本地。支持超时控制、主机密钥自动接受。
以文件是否存在且大小>0 作为成功判定(pscp 会把进度输出到 stderr)。
.PARAMETER Server
服务器连接信息哈希表
.PARAMETER RemotePath
远程文件路径
.PARAMETER LocalPath
本地保存路径
.PARAMETER TimeoutSeconds
超时时间(秒),默认 600
.OUTPUTS
System.Collections.Hashtable
包含 Success, Size, ExitCode, Output/Reason 的结果哈希表
#>
param(
[Parameter(Mandatory=$true)] [hashtable]$Server,
[Parameter(Mandatory=$true)] [string]$RemotePath,
[Parameter(Mandatory=$true)] [string]$LocalPath,
[int]$TimeoutSeconds = 600
)
if (-not $global:PSCP_PATH -or -not (Test-Path $global:PSCP_PATH)) {
Write-Log -Level "ERROR" -Message "[DL] pscp.exe 未找到,无法下载文件: $RemotePath"
return @{ Success = $false; Reason = "pscp not found" }
}
$localDir = Split-Path $LocalPath -Parent
if (-not (Test-Path $localDir)) { New-Item -ItemType Directory -Path $localDir -Force | Out-Null }
$args = @(
"-scp",
"-batch",
"-P", $Server.Port,
"-l", $Server.User,
"-pw", $Server.Pass,
"$($Server.User)@$($Server.IP):$RemotePath",
$LocalPath
)
Write-Log -Level "INFO" -Message ("[DL] pscp 下载(超时 {0}s): {1} {2}" -f $TimeoutSeconds, $global:PSCP_PATH, ($args -join ' '))
# 用临时文件接 stdout/stderr,避免卡住无输出时看起来"没打印"
$tmpOut = Join-Path $env:TEMP ("pscp_out_{0}.log" -f ([guid]::NewGuid().ToString("N")))
$tmpErr = Join-Path $env:TEMP ("pscp_err_{0}.log" -f ([guid]::NewGuid().ToString("N")))
try {
$p = Start-Process -FilePath $global:PSCP_PATH `
-ArgumentList $args `
-NoNewWindow `
-PassThru `
-RedirectStandardOutput $tmpOut `
-RedirectStandardError $tmpErr
$ok = $p.WaitForExit($TimeoutSeconds * 1000)
if (-not $ok) {
try { $p.Kill() } catch {}
Write-Log -Level "ERROR" -Message ("[DL] 下载超时,已终止 pscp (>{0}s): {1}" -f $TimeoutSeconds, $RemotePath)
$outTxt = (Get-Content -Path $tmpOut -ErrorAction SilentlyContinue | Out-String).Trim()
$errTxt = (Get-Content -Path $tmpErr -ErrorAction SilentlyContinue | Out-String).Trim()
if ($outTxt) {
$one = ($outTxt -replace '\s+',' ').Trim()
$n = [math]::Min(500, $one.Length)
Write-Log -Level "ERROR" -Message ("[DL] pscp stdout: {0}" -f $one.Substring(0, $n))
}
if ($errTxt) {
$one = ($errTxt -replace '\s+',' ').Trim()
$n = [math]::Min(500, $one.Length)
Write-Log -Level "ERROR" -Message ("[DL] pscp stderr: {0}" -f $one.Substring(0, $n))
}
return @{ Success = $false; ExitCode = -1; Reason = "timeout" }
}
$code = $p.ExitCode
$outTxt = (Get-Content -Path $tmpOut -ErrorAction SilentlyContinue | Out-String)
$errTxt = (Get-Content -Path $tmpErr -ErrorAction SilentlyContinue | Out-String)
$all = (($outTxt + "`n" + $errTxt) -replace "`r","").Trim()
# host key:第一次连接需要 y,pscp 在 -batch 下会失败,这里自动接受并重试一次
if ($code -ne 0 -and $all -match "host key|Cannot confirm") {
$cmdLine = "echo y | `"$($global:PSCP_PATH)`" -scp -batch -P $($Server.Port) -l $($Server.User) -pw `"$($Server.Pass)`" `"$($Server.User)@$($Server.IP):$RemotePath`" `"$LocalPath`""
Write-Log -Level "WARN" -Message "[DL] 首次连接主机密钥提示,自动接受并重试一次"
$all2 = cmd /c $cmdLine 2>&1
$code2 = $LASTEXITCODE
if ($code2 -eq 0 -and (Test-Path $LocalPath)) {
$size = (Get-Item $LocalPath).Length
Write-Log -Level "SUCCESS" -Message "[DL] 下载成功 ($([math]::Round($size/1024,2)) KB): $LocalPath"
return @{ Success = $true; Size = $size; Output = $all2 }
} else {
$oneLine = (($all2 -join " ") -replace '\s+',' ').Trim()
Write-Log -Level "ERROR" -Message "[DL] 下载失败(重试) ExitCode=$code2, 输出: $oneLine"
return @{ Success = $false; ExitCode = $code2; Output = $all2 }
}
}
$code = $null
try { $code = $p.ExitCode } catch { $code = $null }
$outTxt = (Get-Content -Path $tmpOut -ErrorAction SilentlyContinue | Out-String)
$errTxt = (Get-Content -Path $tmpErr -ErrorAction SilentlyContinue | Out-String)
$all = (($outTxt + "`n" + $errTxt) -replace "`r","").Trim()
# ✅ 关键:以"文件是否存在且大小>0"作为成功判定(pscp 会把进度输出到 stderr,不代表失败)
$fileOk = $false
$size = 0
if (Test-Path -LiteralPath $LocalPath) {
try {
$size = (Get-Item -LiteralPath $LocalPath).Length
if ($size -gt 0) { $fileOk = $true }
} catch { $fileOk = $false }
}
# host key:第一次连接需要 y(只有在明确失败且输出包含 host key 时才走)
if (-not $fileOk -and $all -match "host key|Cannot confirm") {
$cmdLine = "echo y | `"$($global:PSCP_PATH)`" -scp -batch -P $($Server.Port) -l $($Server.User) -pw `"$($Server.Pass)`" `"$($Server.User)@$($Server.IP):$RemotePath`" `"$LocalPath`""
Write-Log -Level "WARN" -Message "[DL] 首次连接主机密钥提示,自动接受并重试一次"
$all2 = cmd /c $cmdLine 2>&1
$code2 = $LASTEXITCODE
if ((Test-Path -LiteralPath $LocalPath) -and ((Get-Item -LiteralPath $LocalPath).Length -gt 0)) {
$size2 = (Get-Item -LiteralPath $LocalPath).Length
Write-Log -Level "SUCCESS" -Message "[DL] 下载成功 ($([math]::Round($size2/1024,2)) KB): $LocalPath"
return @{ Success = $true; Size = $size2; Output = $all2 }
} else {
$oneLine = (($all2 -join " ") -replace '\s+',' ').Trim()
Write-Log -Level "ERROR" -Message "[DL] 下载失败(重试) ExitCode=$code2, 输出: $oneLine"
return @{ Success = $false; ExitCode = $code2; Output = $all2 }
}
}
if ($fileOk) {
Write-Log -Level "SUCCESS" -Message "[DL] 下载成功 ($([math]::Round($size/1024,2)) KB): $LocalPath"
return @{ Success = $true; Size = $size; ExitCode = $code; Output = $all }
} else {
$oneLine = ($all -replace '\s+',' ').Trim()
if ($oneLine.Length -gt 800) { $oneLine = $oneLine.Substring(0,800) + "..." }
Write-Log -Level "ERROR" -Message ("[DL] 下载失败 ExitCode={0}, 输出: {1}" -f $code, $oneLine)
return @{ Success = $false; ExitCode = $code; Output = $all }
}
}
finally {
Remove-Item -Path $tmpOut,$tmpErr -Force -ErrorAction SilentlyContinue | Out-Null
}
}
# ==============================================================================
# 现场数据备份
# ==============================================================================
function DataBakup {
<#
.SYNOPSIS
执行现场数据备份
.DESCRIPTION
在远程服务器上执行数据备份操作:复制关键目录、导出 MySQL 数据库、
打包压缩并下载到本地。目前仅支持传统平台备份。
.PARAMETER Server
服务器连接信息哈希表
.PARAMETER PlatformType
平台类型 ("new" 或 "old")
.PARAMETER SystemInfo
系统信息哈希表(由 Get-SystemType 返回)
.OUTPUTS
System.Collections.Hashtable
包含 Summary 的结果哈希表
#>
param (
[Parameter(Mandatory=$true)] [hashtable]$Server,
[Parameter(Mandatory=$true)] [ValidateSet('new','old')] [string]$PlatformType,
[Parameter(Mandatory=$true)] [hashtable]$SystemInfo
)
Write-Log -Level "INFO" -Message "开始现场数据备份 (平台: $PlatformType) ..."
$bakDir = "/home/bakup"
$cmds = @(
"set -e",
"mkdir -p $bakDir"
)
# ✅ 修复:SystemInfo key 对齐 Get-SystemType 的返回结构
$hasUjava = [bool]($SystemInfo.HasUjava)
$hasUpython = [bool]($SystemInfo.HasUpython)
$hasCardtable = ($SystemInfo.ContainsKey('cardtable') -and $SystemInfo['cardtable'])
$hasPaperless = ($SystemInfo.ContainsKey('paperless') -and $SystemInfo['paperless'])
$hasUmysql = ($SystemInfo.ContainsKey('umysql') -and $SystemInfo['umysql'])
$ujavaVariant = "meeting"
if ($SystemInfo.ContainsKey('UjavaSystemVariant') -and $SystemInfo.UjavaSystemVariant) {
$ujavaVariant = [string]$SystemInfo.UjavaSystemVariant
}
# 仅实现传统平台备份
if ($PlatformType -eq 'old') {
# ✅ ujava:按 meeting/unified 分支备份
if ($hasUjava) {
if ($ujavaVariant -eq 'unified') {
$cmds += "[ -d /var/www/java/unifiedPlatform ] && cp -a /var/www/java/unifiedPlatform $bakDir/"
} else {
$cmds += "[ -d /var/www/java ] && cp -a /var/www/java $bakDir/"
}
}
if ($hasUpython) {
$cmds += "[ -d /var/www/html ] && cp -a /var/www/html $bakDir/"
}
$cmds += "[ -d /var/www/emqx ] && cp -a /var/www/emqx $bakDir/"
$cmds += "[ -d /var/www/redis ] && cp -a /var/www/redis $bakDir/"
} else {
Write-Log -Level "INFO" -Message "[BAK] 新统一平台备份:开始执行..."
# 新平台备份目录
if (Test-PathVariable -Name "hasUjava" -Value $hasUjava) {
# 新平台服务目录
$cmds += "[ -d /data/services ] && cp -a /data/services $bakDir/"
}
# 中间件配置
$cmds += "[ -d /data/middleware ] && cp -a /data/middleware $bakDir/"
# 第三方应用
$cmds += "[ -d /data/third_party ] && cp -a /data/third_party $bakDir/"
Write-Log -Level "INFO" -Message "[BAK] 新平台备份项: /data/services, /data/middleware, /data/third_party"
}
# ✅ umysql 判定建议改为:直接远端检测容器,而不是 SystemInfo['umysql']
if ($PlatformType -eq 'old') {
$dbUser = "root"; $dbPass = "dNrprU&2S"; $dbs = @("ubains","devops")
$cmds += "mkdir -p /tmp/bak_sql"
foreach ($db in $dbs) {
$cmds += "if docker ps --format '{{.Names}}' | grep -q '^umysql$'; then docker exec umysql sh -c ""mysqldump -u${dbUser} -p'${dbPass}' --single-transaction --quick --lock-tables=false ${db}"" > /tmp/bak_sql/${db}_$(date +%Y%m%d%H%M%S).sql; fi || true"
}
$cmds += "if ls /tmp/bak_sql/*.sql >/dev/null 2>&1; then cp -a /tmp/bak_sql/*.sql $bakDir/; fi || true"
} elseif ($PlatformType -eq 'new') {
# 新平台 MySQL 导出
$dbUser = "root"; $dbPass = "dNrprU&2S"; $dbs = @("ubains","devops")
$cmds += "mkdir -p /tmp/bak_sql"
foreach ($db in $dbs) {
$cmds += "if docker ps --format '{{.Names}}' | grep -q '^umysql$'; then docker exec umysql sh -c ""mysqldump -u${dbUser} -p'${dbPass}' --single-transaction --quick --lock-tables=false ${db}"" > /tmp/bak_sql/${db}_$(date +%Y%m%d%H%M%S).sql; fi || true"
}
$cmds += "if ls /tmp/bak_sql/*.sql >/dev/null 2>&1; then cp -a /tmp/bak_sql/*.sql $bakDir/; fi || true"
}
Write-Log -Level "INFO" -Message "[BAK] 创建备份目录: $bakDir"
# 展示计划复制的目录
Write-Log -Level "INFO" -Message "[BAK] 平台: $PlatformType, 容器: ujava=$hasUjava upython=$hasUpython cardtable=$hasCardtable paperless=$hasPaperless umysql=$hasUmysql"
foreach ($c in $cmds) { Write-Log -Level "INFO" -Message "[BAK] 计划执行: $c" }
$joined = ($cmds -join '; ')
Write-Log -Level "INFO" -Message "[BAK] 执行远程备份命令"
$res = Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $joined
$bakOutput = if ($res.Output) { [string]::Join(' ', $res.Output) } else { '' }
Write-Log -Level "INFO" -Message "[BAK] 备份输出: $bakOutput"
if ($res.ExitCode -ne 0) { Write-Log -Level "ERROR" -Message "[BAK] 远程备份步骤失败"; return @{ Summary = "失败" } }
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"; $tarName = "bakup_${timestamp}.tar.gz"; $tarPath = "/home/$tarName"
$packCmd = "set -e; tar -czf $tarPath -C /home bakup"
Write-Log -Level "INFO" -Message "[BAK] 压缩命令: $packCmd"
$res2 = Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $packCmd
$packOutput = if ($res2.Output) { [string]::Join(' ', $res2.Output) } else { '' }
Write-Log -Level "INFO" -Message "[BAK] 压缩输出: $packOutput"
if ($res2.ExitCode -ne 0 -or $packOutput -match "error") { Write-Log -Level "ERROR" -Message "[BAK] 压缩备份失败"; return @{ Summary = "失败" } }
if (-not $global:PSCP_PATH) {
Write-Log -Level "ERROR" -Message "[BAK] 未找到 pscp.exe,无法下载备份文件"; $downloadSummary = "未下载"
} else {
# 使用 ASCII 路径避免编码问题
$localOutDir = Join-Path $global:SCRIPT_DIR "Downloads"
if (-not (Test-Path $localOutDir)) { New-Item -ItemType Directory -Path $localOutDir | Out-Null }
$localFile = Join-Path $localOutDir $tarName
# 检查磁盘空间(尽量检查,失败则忽略,不中断流程)
try {
$qualifier = Split-Path $localOutDir -Qualifier
if ($qualifier) {
$drive = Get-PSDrive -Name $qualifier.TrimEnd(':')
if ($drive -and ($drive.Free -lt 1GB)) {
Write-Log -Level "WARN" -Message "[BAK] 本地磁盘可用空间不足 1GB,可能导致下载失败"
}
}
} catch {
Write-Log -Level "WARN" -Message "[BAK] 无法检测本地磁盘空间,已忽略: $($_.Exception.Message)"
}
# 构造下载命令,添加 -batch 防交互
$pscpCmd = "`"$($global:PSCP_PATH)`" -batch -scp -P $($Server.Port) -pw `"$($Server.Pass)`" $($Server.User)@$($Server.IP):$tarPath `"$localFile`""
Write-Log -Level "INFO" -Message "[BAK] 下载命令: $pscpCmd"
$dl = & powershell -NoProfile -Command $pscpCmd
if ($LASTEXITCODE -ne 0) {
Write-Log -Level "ERROR" -Message "[BAK] 下载备份失败,尝试使用 TEMP 目录重试"
$fallbackDir = Join-Path $env:TEMP "ubains_downloads"
if (-not (Test-Path $fallbackDir)) { New-Item -ItemType Directory -Path $fallbackDir -Force | Out-Null }
$fallbackFile = Join-Path $fallbackDir $tarName
$pscpCmd2 = "`"$($global:PSCP_PATH)`" -batch -scp -P $($Server.Port) -pw `"$($Server.Pass)`" $($Server.User)@$($Server.IP):$tarPath `"$fallbackFile`""
Write-Log -Level "INFO" -Message "[BAK] 重试下载命令: $pscpCmd2"
$dl2 = & powershell -NoProfile -Command $pscpCmd2
if ($LASTEXITCODE -ne 0) {
Write-Log -Level "ERROR" -Message "[BAK] 重试下载失败"
$downloadSummary = "下载失败"
} else {
Write-Log -Level "SUCCESS" -Message "[BAK] 备份已下载: $fallbackFile"
$downloadSummary = "已下载(TEMP)"
}
} else {
Write-Log -Level "SUCCESS" -Message "[BAK] 备份已下载: $localFile"
$downloadSummary = "已下载"
}
}
$cleanCmd = "rm -rf $bakDir; rm -f $tarPath; rm -rf /tmp/bak_sql"; Write-Log -Level "INFO" -Message "[BAK] 清理命令: $cleanCmd"
[void] (Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $cleanCmd)
Write-Log -Level "INFO" -Message "[BAK] 清理完成"
Write-Log -Level "INFO" -Message "[BAK] 备份流程完成"
return @{ Summary = "完成 ($downloadSummary)" }
}
# ==============================================================================
# 导出模块函数
# ==============================================================================
Export-ModuleMember -Function @(
'Download-RemoteFile',
'DataBakup'
)
# ==============================================================================
# FilePermission.psm1
# ------------------------------------------------------------------------------
# 远程文件权限检查与修复模块
#
# .SYNOPSIS
# 提供远程文件权限检查和自动修复功能
#
# .DESCRIPTION
# 本模块包含:
# - Invoke-RemoteFilePermissionFix: 上传并执行权限修复脚本
# - Check-FilePermissions: 检查远程文件权限,可选自动修复
#
# .NOTES
# 版本:1.0.0
# 创建日期:2026-06-06
# 从 check_server_health.ps1 主脚本拆分而来
#
# ==============================================================================
# 导入公共模块
$ModuleDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$CommonModulePath = Join-Path $ModuleDir "Common.psm1"
if (Test-Path $CommonModulePath) {
Import-Module $CommonModulePath -Force -Global -ErrorAction SilentlyContinue
}
# ==============================================================================
# 远程文件权限修复
# ==============================================================================
function Invoke-RemoteFilePermissionFix {
<#
.SYNOPSIS
上传并执行远程文件权限修复脚本
.DESCRIPTION
将 issue_handler.sh 上传到远程服务器并执行权限修复命令。
使用非交互模式运行修复脚本。
.PARAMETER Server
服务器连接信息哈希表
.PARAMETER PlatformType
平台类型 ("new" 或 "old")
.OUTPUTS
System.Collections.Hashtable
包含 Success 和 Error 的结果哈希表
#>
param(
[Parameter(Mandatory=$true)] [hashtable]$Server,
[Parameter(Mandatory=$true)] [string]$PlatformType
)
Write-Log -Level "INFO" -Message "[PERM] 开始远程文件权限修复"
# 确定平台类型参数
$platformParam = if ($PlatformType -eq 'new') { 'new' } else { 'standard' }
# 上传修复脚本
$fixScriptPath = Join-Path $global:SCRIPT_DIR "问题处理\issue_handler.sh"
if (-not (Test-Path $fixScriptPath)) {
Write-Log -Level "ERROR" -Message "[PERM] 修复脚本不存在: $fixScriptPath"
return @{ Success = $false; Error = "修复脚本不存在" }
}
Write-Log -Level "INFO" -Message "[PERM] 上传修复脚本到远程服务器..."
# 创建远程临时目录
$remoteDir = "/tmp/permission_fix_$(Get-Date -Format 'yyyyMMddHHmmss')"
$cmd = "mkdir -p $remoteDir"
Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $cmd | Out-Null
# 上传issue_handler.sh
$uploadResult = Upload-ShellScript -Server $Server -ScriptName "issue_handler.sh" -RemotePath $remoteDir
if (-not $uploadResult) {
Write-Log -Level "ERROR" -Message "[PERM] 上传修复脚本失败"
return @{ Success = $false; Error = "上传修复脚本失败" }
}
Write-Log -Level "INFO" -Message "[PERM] 执行权限修复命令..."
# 执行修复命令(非交互模式)
$cmd = "cd $remoteDir && bash issue_handler.sh --action fix_permissions --platform $platformParam --non-interactive --yes"
$result = Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $cmd
# 检查执行结果
if ($result.ExitCode -eq 0) {
Write-Log -Level "SUCCESS" -Message "[PERM] 权限修复执行成功"
Write-Log -Level "INFO" -Message "[PERM] 修复输出: $($result.Output -join "`n")"
# 清理远程临时目录
Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command "rm -rf $remoteDir" | Out-Null
return @{ Success = $true }
} else {
Write-Log -Level "ERROR" -Message "[PERM] 权限修复执行失败 (退出码: $($result.ExitCode))"
Write-Log -Level "ERROR" -Message "[PERM] 错误输出: $($result.Output -join "`n")"
# 清理远程临时目录
Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command "rm -rf $remoteDir" | Out-Null
return @{ Success = $false; Error = "执行失败 (退出码: $($result.ExitCode))" }
}
}
# ==============================================================================
# 检测文件权限
# ==============================================================================
function Check-FilePermissions {
<#
.SYNOPSIS
检查远程服务器上的文件权限
.DESCRIPTION
检查远程服务器上关键文件和目录的权限。支持新统一平台和传统平台两套路径列表。
如果发现缺失文件且有修复脚本,可交互式触发自动修复。
.PARAMETER Server
服务器连接信息哈希表
.PARAMETER PlatformType
平台类型 ("new" 或 "old")
.PARAMETER SystemInfo
系统信息哈希表(可选,用于传统平台路径分支)
.OUTPUTS
System.Collections.Hashtable
包含 Summary 和 Lines 的结果哈希表
#>
param (
[Parameter(Mandatory=$true)] [hashtable]$Server,
[Parameter(Mandatory=$true)] [ValidateSet('new','old')] [string]$PlatformType,
[Parameter(Mandatory=$false)] [hashtable]$SystemInfo
)
Write-Log -Level "INFO" -Message "开始文件权限检测 (平台: $PlatformType) ..."
$targets = @()
if ($PlatformType -eq 'new') {
$targets += @(
"/data/services/api/auth/auth-sso-auth/run.sh",
"/data/services/api/auth/auth-sso-gatway/run.sh",
"/data/services/api/auth/auth-sso-system/run.sh",
"/data/services/api/java-meeting/java-meeting2.0/run.sh",
"/data/services/api/java-meeting/java-meeting-extapi/run.sh",
"/data/services/api/java-meeting/java-message-scheduling/run.sh",
"/data/services/api/java-meeting/java-mqtt/run.sh",
"/data/services/api/java-meeting/java-quartz/run.sh",
"/data/services/api/start.sh",
"/data/services/scripts/*.sh",
"/data/third_party/paperless/run.sh",
"/data/third_party/paperless/start.sh",
"/data/third_party/wifi-local/config.ini",
"/data/third_party/wifi-local/startDB.sh",
"/data/third_party/wifi-local/wifi*",
"/etc/rc.d/rc.local",
"/data/middleware/nginx/config/*.conf",
"/data/middleware/emqx/config/*.conf",
"/data/services/api/python-cmdb/*.sh",
"/data/services/api/python-voice/*.sh",
"/data/middleware/mysql/conf/my.cnf"
)
} else {
# ✅ 传统平台:根据 ujava 系统细分调整路径
$ujavaVariant = $null
if ($SystemInfo -and $SystemInfo.ContainsKey('UjavaSystemVariant')) { $ujavaVariant = $SystemInfo.UjavaSystemVariant }
$targets += @(
"/var/www/java/api-java-meeting2.0/run.sh",
"/var/www/java/external-meeting-api/run.sh",
"/var/www/html/start.sh",
"/var/www/wifi-local/config.ini",
"/var/www/wifi-local/startDB.sh",
"/var/www/wifi-local/wifi*",
"/var/www/paperless/run.sh",
"/var/www/paperless/start.sh",
"/var/www/redis/redis-*.conf",
"/var/www/emqx/config/*.conf",
"/etc/rc.d/rc.local",
"/usr/local/docker/mysql/my.cnf"
)
# start.sh 路径分支:unified 优先检查 unifiedPlatform/start.sh
if ($ujavaVariant -eq 'unified') {
$targets += "/var/www/java/unifiedPlatform/start.sh"
$targets += "/var/www/java/unifiedPlatform/nginx-conf.d/*.conf"
} else {
$targets += "/var/www/java/start.sh"
$targets += "/var/www/java/nginx-conf.d/*.conf"
}
}
Write-Log -Level "INFO" -Message "[PERM] 目标列表生成 (平台: $PlatformType)"
foreach ($path in $targets) { Write-Log -Level "INFO" -Message "[PERM] 待检查: $path" }
$lines = @()
foreach ($path in $targets) {
$cmd = "if ls -l $path 2>/dev/null; then echo '__PERM_OK__'; else echo '__PERM_MISS__ $path'; fi"
Write-Log -Level "INFO" -Message "[PERM] 执行: $cmd"
$res = Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $cmd
$out = if ($res.Output) { ($res.Output -join " ") } else { "" }
Write-Log -Level "INFO" -Message "[PERM] 输出: $out"
if ($out -match "__PERM_OK__") {
($res.Output) | Where-Object { $_ -match "^[-dl]" } | ForEach-Object {
Write-Log -Level 'SUCCESS' -Message "[PERM] 权限: $_"; $lines += $_
}
} else { Write-Log -Level 'WARN' -Message "[PERM] 未找到: $path"; $lines += "MISS $path" }
}
Write-Log -Level "INFO" -Message "[PERM] 检测结束: 共 $($lines.Count) 项"
# 区分存在与缺失的统计
$foundCount = @($lines | Where-Object { $_ -notlike "MISS*" }).Count
$missCount = @($lines | Where-Object { $_ -like "MISS*" }).Count
$summaryText = "已检查: 找到 $foundCount 项, 缺失 $missCount 项"
# 如果有缺失文件,询问是否自动修复
if ($missCount -gt 0) {
Write-Log -Level "WARN" -Message "[PERM] 发现 $missCount 个缺失文件,询问是否自动修复..."
# 检查是否有修复脚本
$fixScriptPath = Join-Path $global:SCRIPT_DIR "问题处理\issue_handler.sh"
$hasFixScript = Test-Path $fixScriptPath
if ($hasFixScript) {
Write-Log -Level "INFO" -Message "[PERM] 检测到修复脚本: $fixScriptPath"
# 自动修复提示
$fixChoice = Read-Host "[PERM] 是否自动修复文件权限? (y/N)"
if ($fixChoice -eq 'y' -or $fixChoice -eq 'Y') {
Write-Log -Level "INFO" -Message "[PERM] 用户确认自动修复,开始执行..."
# 上传并执行修复脚本
$fixResult = Invoke-RemoteFilePermissionFix -Server $Server -PlatformType $PlatformType
if ($fixResult.Success) {
Write-Log -Level "SUCCESS" -Message "[PERM] 文件权限修复成功"
$summaryText += " (已自动修复)"
# 重新检测权限
Write-Log -Level "INFO" -Message "[PERM] 重新检测文件权限..."
$lines = @()
foreach ($path in $targets) {
$cmd = "if ls -l $path 2>/dev/null; then echo '__PERM_OK__'; else echo '__PERM_MISS__ $path'; fi"
$res = Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $cmd
$out = if ($res.Output) { ($res.Output -join " ") } else { "" }
if ($out -match "__PERM_OK__") {
($res.Output) | Where-Object { $_ -match "^[-dl]" } | ForEach-Object {
Write-Log -Level 'INFO' -Message "[PERM] 权限: $_"; $lines += $_
}
} else { Write-Log -Level 'WARN' -Message "[PERM] 未找到: $path"; $lines += "MISS $path" }
}
# 更新统计
$foundCount = @($lines | Where-Object { $_ -notlike "MISS*" }).Count
$missCount = @($lines | Where-Object { $_ -like "MISS*" }).Count
$summaryText = "已检查: 找到 $foundCount 项, 缺失 $missCount 项 (已自动修复)"
} else {
Write-Log -Level "ERROR" -Message "[PERM] 文件权限修复失败: $($fixResult.Error)"
}
} else {
Write-Log -Level "INFO" -Message "[PERM] 用户取消自动修复"
}
} else {
Write-Log -Level "WARN" -Message "[PERM] 未找到修复脚本,跳过自动修复"
}
}
return @{ Summary = $summaryText; Lines = $lines }
}
# ==============================================================================
# 导出模块函数
# ==============================================================================
Export-ModuleMember -Function @(
'Invoke-RemoteFilePermissionFix',
'Check-FilePermissions'
)
......@@ -1217,9 +1217,510 @@ timeout 60 bash "__SCRIPT_PATH__" 2>&1; echo "===EXIT_CODE===$?"
#endregion FastDFS Connection Check
#region MySQL Deep Check (PRD 2.2)
<#
.SYNOPSIS
MySQL 深度检测函数
.DESCRIPTION
对 MySQL 进行深度性能检测,包括缓冲池命中率、慢查询状态、连接使用率、
主从复制状态、TOP20 大表等。
降级策略:如果 docker exec umysql mysql 执行失败,跳过深度检测。
.PARAMETER Server
包含服务器连接信息的哈希表
.OUTPUTS
System.Collections.Hashtable[]
返回包含检测结果哈希表的数组
#>
function Test-MySQLDeepCheck {
param(
[Parameter(Mandatory=$true)]
[hashtable]$Server
)
Write-Log -Level "INFO" -Message "========== MySQL 深度检测 =========="
$results = @()
$containerName = $MiddlewareConfig.MySQL.ContainerName
$mysqlPassword = $MiddlewareConfig.MySQL.Password
# 检查MySQL容器是否存在
$checkCmd = "docker ps --format '{{.Names}}' | grep -E '${containerName}|mysql' | head -n 1"
$checkResult = Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $checkCmd
if ($checkResult.ExitCode -ne 0 -or -not $checkResult.Output) {
Write-Log -Level "INFO" -Message "[MySQL深度] 未检测到MySQL容器,跳过深度检测"
return $results
}
$actualContainer = (@($checkResult.Output)[0].ToString().Trim() -replace "`r","")
Write-Log -Level "INFO" -Message "[MySQL深度] 容器: $actualContainer"
# 辅助:通过 docker exec -i 管道方式执行 SQL 查询
# 密码用单引号保护特殊字符(如 & ),-i 允许通过 stdin 管道传入 SQL
$mysqlPipe = "docker exec -i $actualContainer mysql -uroot -p'$mysqlPassword' -N 2>/dev/null"
# --- 1. 缓冲池命中率 ---
try {
$bpSql = "SHOW STATUS LIKE 'Innodb_buffer_pool_read%';"
$bpCmd = "echo ""$bpSql"" | $mysqlPipe"
Write-Log -Level "INFO" -Message "[MySQL深度] 执行: $bpCmd"
$bpResult = Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $bpCmd
if ($bpResult.ExitCode -eq 0 -and $bpResult.Output) {
$bpOutput = $bpResult.Output -join "`n"
$readRequests = 0; $readDisk = 0
if ($bpOutput -match 'Innodb_buffer_pool_read_requests\s+(\d+)') { $readRequests = [long]$matches[1] }
if ($bpOutput -match 'Innodb_buffer_pool_reads\s+(\d+)') { $readDisk = [long]$matches[1] }
$hitRate = 0.0
if ($readRequests -gt 0) {
$hitRate = [math]::Round(($readRequests - $readDisk) / $readRequests * 100, 2)
}
$bpStatus = if ($hitRate -ge 95) { "正常" } elseif ($hitRate -ge 80) { "警告" } else { "异常" }
$bpColor = if ($hitRate -ge 95) { "SUCCESS" } elseif ($hitRate -ge 80) { "WARN" } else { "ERROR" }
Write-Log -Level $bpColor -Message "[MySQL深度] 缓冲池命中率: ${hitRate}% [$bpStatus]"
$results += @{
Check = "MySQL 缓冲池命中率"
Status = $bpStatus
Details = "命中率: ${hitRate}% | 读请求: $readRequests | 磁盘读: $readDisk"
Value = "${hitRate}%"
Success = ($hitRate -ge 80)
}
}
} catch {
Write-Log -Level "WARN" -Message "[MySQL深度] 缓冲池命中率检测异常: $($_.Exception.Message)"
}
# --- 2. 慢查询状态 ---
try {
$slowSql = "SHOW VARIABLES LIKE 'slow_query_log'; SHOW STATUS LIKE 'Slow_queries';"
$slowCmd = "echo ""$slowSql"" | $mysqlPipe"
Write-Log -Level "INFO" -Message "[MySQL深度] 执行: $slowCmd"
$slowResult = Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $slowCmd
if ($slowResult.ExitCode -eq 0 -and $slowResult.Output) {
$slowOutput = $slowResult.Output -join "`n"
$slowEnabled = "OFF"; $slowCount = 0
if ($slowOutput -match 'slow_query_log\s+(ON|OFF)') { $slowEnabled = $matches[1] }
if ($slowOutput -match 'Slow_queries\s+(\d+)') { $slowCount = [long]$matches[1] }
$slowStatus = if ($slowEnabled -eq "ON") { "正常" } else { "警告" }
$slowColor = if ($slowEnabled -eq "ON") { "SUCCESS" } else { "WARN" }
Write-Log -Level $slowColor -Message "[MySQL深度] 慢查询日志: $slowEnabled | 慢查询数: $slowCount [$slowStatus]"
$results += @{
Check = "MySQL 慢查询状态"
Status = $slowStatus
Details = "慢查询日志: $slowEnabled | 慢查询数: $slowCount"
Value = "$slowEnabled ($slowCount)"
Success = ($slowEnabled -eq "ON")
}
}
} catch {
Write-Log -Level "WARN" -Message "[MySQL深度] 慢查询状态检测异常: $($_.Exception.Message)"
}
# --- 3. 连接使用率 ---
try {
$connSql = "SHOW STATUS LIKE 'Threads_connected'; SHOW VARIABLES LIKE 'max_connections';"
$connCmd = "echo ""$connSql"" | $mysqlPipe"
Write-Log -Level "INFO" -Message "[MySQL深度] 执行: $connCmd"
$connResult = Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $connCmd
if ($connResult.ExitCode -eq 0 -and $connResult.Output) {
$connOutput = $connResult.Output -join "`n"
$currentConn = 0; $maxConn = 0
if ($connOutput -match 'Threads_connected\s+(\d+)') { $currentConn = [int]$matches[1] }
if ($connOutput -match 'max_connections\s+(\d+)') { $maxConn = [int]$matches[1] }
$connRate = 0.0
if ($maxConn -gt 0) {
$connRate = [math]::Round($currentConn / $maxConn * 100, 1)
}
$connStatus = if ($connRate -lt 80) { "正常" } else { "警告" }
$connColor = if ($connRate -lt 80) { "SUCCESS" } else { "WARN" }
Write-Log -Level $connColor -Message "[MySQL深度] 连接使用率: ${connRate}% ($currentConn/$maxConn) [$connStatus]"
$results += @{
Check = "MySQL 连接使用率"
Status = $connStatus
Details = "当前连接: $currentConn / 最大: $maxConn (${connRate}%)"
Value = "${connRate}%"
Success = ($connRate -lt 80)
}
}
} catch {
Write-Log -Level "WARN" -Message "[MySQL深度] 连接使用率检测异常: $($_.Exception.Message)"
}
# --- 4. 主从复制状态 ---
try {
$replSql = "SHOW SLAVE STATUS\G"
$replCmd = "echo ""$replSql"" | $mysqlPipe"
Write-Log -Level "INFO" -Message "[MySQL深度] 执行: $replCmd"
$replResult = Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $replCmd
if ($replResult.ExitCode -eq 0 -and $replResult.Output) {
$replOutput = $replResult.Output -join "`n"
if ($replOutput -match '\S' -and $replOutput -notmatch 'Empty set') {
$ioRunning = ""; $sqlRunning = ""; $secondsBehind = ""
if ($replOutput -match 'Slave_IO_Running:\s*(\S+)') { $ioRunning = $matches[1] }
if ($replOutput -match 'Slave_SQL_Running:\s*(\S+)') { $sqlRunning = $matches[1] }
if ($replOutput -match 'Seconds_Behind_Master:\s*(\S+)') { $secondsBehind = $matches[1] }
$replOk = ($ioRunning -eq "Yes" -and $sqlRunning -eq "Yes")
$replStatus = if ($replOk) { "正常" } else { "异常" }
$replColor = if ($replOk) { "SUCCESS" } else { "ERROR" }
Write-Log -Level $replColor -Message "[MySQL深度] 主从复制: IO=$ioRunning SQL=$sqlRunning 延迟=${secondsBehind}s [$replStatus]"
$results += @{
Check = "MySQL 主从复制状态"
Status = $replStatus
Details = "IO线程: $ioRunning | SQL线程: $sqlRunning | 延迟: ${secondsBehind}s"
Value = "IO=$ioRunning SQL=$sqlRunning"
Success = $replOk
}
} else {
Write-Log -Level "INFO" -Message "[MySQL深度] 未配置主从复制(非异常)"
$results += @{
Check = "MySQL 主从复制状态"
Status = "正常"
Details = "未配置主从复制(单机模式)"
Value = "单机"
Success = $true
}
}
}
} catch {
Write-Log -Level "WARN" -Message "[MySQL深度] 主从复制状态检测异常: $($_.Exception.Message)"
}
# --- 5. TOP20 大表 ---
try {
$topTableSql = "SELECT table_name, ROUND(data_length/1024/1024,2) AS data_mb, ROUND(index_length/1024/1024,2) AS idx_mb, table_rows FROM information_schema.TABLES WHERE table_schema NOT IN ('mysql','information_schema','performance_schema','sys') ORDER BY data_length DESC LIMIT 20;"
$topTableCmd = "echo ""$topTableSql"" | $mysqlPipe"
Write-Log -Level "INFO" -Message "[MySQL深度] 执行: $topTableCmd"
$topTableResult = Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $topTableCmd
if ($topTableResult.ExitCode -eq 0 -and $topTableResult.Output) {
$topTableOutput = $topTableResult.Output -join "`n"
$tableLines = $topTableOutput -split "`n" | Where-Object { $_ -match '\S' }
$tableSummary = ($tableLines | Select-Object -First 5 | ForEach-Object { $_.Trim() }) -join ' | '
Write-Log -Level "INFO" -Message "[MySQL深度] TOP20大表: $($tableLines.Count) 个表"
$results += @{
Check = "MySQL TOP20 大表"
Status = "正常"
Details = "共 $($tableLines.Count) 个表(按数据大小排序)| Top5: $tableSummary"
Value = "$($tableLines.Count)"
Success = $true
}
}
} catch {
Write-Log -Level "WARN" -Message "[MySQL深度] TOP20大表检测异常: $($_.Exception.Message)"
}
# --- 6. QPS/TPS 性能基线 ---
try {
$qpsSql = "SHOW STATUS LIKE 'Queries'; SHOW STATUS LIKE 'Uptime';"
$qpsCmd = "echo ""$qpsSql"" | $mysqlPipe"
Write-Log -Level "INFO" -Message "[MySQL深度] 执行: $qpsCmd"
$qpsResult = Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $qpsCmd
if ($qpsResult.ExitCode -eq 0 -and $qpsResult.Output) {
$qpsOutput = $qpsResult.Output -join "`n"
$queries = 0; $uptime = 0
if ($qpsOutput -match 'Queries\s+(\d+)') { $queries = [long]$matches[1] }
if ($qpsOutput -match 'Uptime\s+(\d+)') { $uptime = [long]$matches[1] }
$qps = 0.0
if ($uptime -gt 0) {
$qps = [math]::Round($queries / $uptime, 2)
}
Write-Log -Level "INFO" -Message "[MySQL深度] QPS: $qps | 总查询: $queries | 运行时间: ${uptime}s"
$results += @{
Check = "MySQL QPS 性能基线"
Status = "正常"
Details = "平均QPS: $qps | 总查询数: $queries | 运行时间: ${uptime}s"
Value = "$qps"
Success = $true
}
}
} catch {
Write-Log -Level "WARN" -Message "[MySQL深度] QPS检测异常: $($_.Exception.Message)"
}
Write-Log -Level "INFO" -Message "========== MySQL 深度检测完成(共 $($results.Count) 项) =========="
return $results
}
#endregion MySQL Deep Check (PRD 2.2)
#region Redis Deep Check (PRD 2.3)
<#
.SYNOPSIS
Redis 深度检测函数
.DESCRIPTION
对 Redis 进行深度性能检测,包括RDB持久化、AOF持久化、内存碎片率、
缓存命中率、键空间统计、主从复制状态。
降级策略:如果 redis-cli 不可用或密码认证失败,跳过深度检测。
.PARAMETER Server
包含服务器连接信息的哈希表
.OUTPUTS
System.Collections.Hashtable[]
返回包含检测结果哈希表的数组
#>
function Test-RedisDeepCheck {
param(
[Parameter(Mandatory=$true)]
[hashtable]$Server
)
Write-Log -Level "INFO" -Message "========== Redis 深度检测 =========="
$results = @()
$containerName = $MiddlewareConfig.Redis.ContainerName
$redisPassword = $MiddlewareConfig.Redis.Password
# 检查Redis容器是否存在
$checkCmd = "docker ps --format '{{.Names}}' | grep -E '${containerName}|redis' | head -n 1"
$checkResult = Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $checkCmd
if ($checkResult.ExitCode -ne 0 -or -not $checkResult.Output) {
Write-Log -Level "INFO" -Message "[Redis深度] 未检测到Redis容器,跳过深度检测"
return $results
}
$actualContainer = (@($checkResult.Output)[0].ToString().Trim() -replace "`r","")
Write-Log -Level "INFO" -Message "[Redis深度] 容器: $actualContainer"
# 构造 redis-cli 命令基础(密码用单引号保护特殊字符如 &)
$redisCliBase = "docker exec $actualContainer redis-cli -a '$redisPassword' --no-auth-warning"
# --- 1. RDB 持久化状态 ---
try {
$rdbCmd = "$redisCliBase INFO Persistence 2>/dev/null"
$rdbResult = Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $rdbCmd
if ($rdbResult.ExitCode -eq 0 -and $rdbResult.Output) {
$rdbOutput = $rdbResult.Output -join "`n"
$rdbStatus = ""; $rdbLastSave = ""
if ($rdbOutput -match 'rdb_last_bgsave_status:(\S+)') { $rdbStatus = $matches[1] }
if ($rdbOutput -match 'rdb_last_save_time:(\d+)') { $rdbLastSave = $matches[1] }
$rdbOk = ($rdbStatus -eq "ok")
# 计算最后保存时间距今天数
$rdbAge = ""
if ($rdbLastSave) {
try {
$saveTime = [DateTimeOffset]::FromUnixTimeSeconds([long]$rdbLastSave).DateTime
$rdbAge = "$([math]::Round(((Get-Date) - $saveTime).TotalHours, 1))小时前"
if (((Get-Date) - $saveTime).TotalHours -gt 1) {
$rdbOk = $false
}
} catch {
$rdbAge = "未知"
}
}
$rdbCheckStatus = if ($rdbStatus -eq "ok") { "正常" } else { "异常" }
$rdbColor = if ($rdbCheckStatus -eq "正常") { "SUCCESS" } else { "ERROR" }
Write-Log -Level $rdbColor -Message "[Redis深度] RDB持久化: $rdbStatus | 最后保存: $rdbAge [$rdbCheckStatus]"
$results += @{
Check = "Redis RDB 持久化"
Status = $rdbCheckStatus
Details = "bgsave状态: $rdbStatus | 最后保存: $rdbAge"
Value = $rdbStatus
Success = ($rdbStatus -eq "ok")
}
}
} catch {
Write-Log -Level "WARN" -Message "[Redis深度] RDB持久化检测异常: $($_.Exception.Message)"
}
# --- 2. AOF 持久化状态 ---
try {
$aofCmd = "$redisCliBase INFO Persistence 2>/dev/null"
$aofResult = Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $aofCmd
if ($aofResult.ExitCode -eq 0 -and $aofResult.Output) {
$aofOutput = $aofResult.Result.Output -join "`n"
# 复用上面的 rdbOutput(如果可用)或重新查询
if ($rdbResult -and $rdbResult.Output) {
$aofOutput = $rdbResult.Output -join "`n"
} else {
$aofOutput = ""
}
$aofEnabled = ""; $aofWriteStatus = ""
if ($aofOutput -match 'aof_enabled:(\d+)') { $aofEnabled = $matches[1] }
if ($aofOutput -match 'aof_last_write_status:(\S+)') { $aofWriteStatus = $matches[1] }
$aofLabel = if ($aofEnabled -eq "1") { "已启用" } else { "未启用" }
$aofCheckStatus = "正常"
if ($aofEnabled -eq "1" -and $aofWriteStatus -ne "ok") {
$aofCheckStatus = "异常"
} elseif ($aofEnabled -ne "1") {
$aofCheckStatus = "建议"
}
$aofColor = if ($aofCheckStatus -eq "正常") { "SUCCESS" } elseif ($aofCheckStatus -eq "建议") { "WARN" } else { "ERROR" }
Write-Log -Level $aofColor -Message "[Redis深度] AOF持久化: $aofLabel | 写入状态: $aofWriteStatus [$aofCheckStatus]"
$results += @{
Check = "Redis AOF 持久化"
Status = $aofCheckStatus
Details = "AOF: $aofLabel | 写入状态: $aofWriteStatus"
Value = $aofLabel
Success = ($aofCheckStatus -ne "异常")
}
}
} catch {
Write-Log -Level "WARN" -Message "[Redis深度] AOF持久化检测异常: $($_.Exception.Message)"
}
# --- 3. 内存碎片率 ---
try {
$memCmd = "$redisCliBase INFO Memory 2>/dev/null"
$memResult = Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $memCmd
if ($memResult.ExitCode -eq 0 -and $memResult.Output) {
$memOutput = $memResult.Output -join "`n"
$fragRatio = 0.0
if ($memOutput -match 'mem_fragmentation_ratio:([\d.]+)') { $fragRatio = [double]$matches[1] }
$fragStatus = if ($fragRatio -lt 1.5) { "正常" } elseif ($fragRatio -lt 2.0) { "警告" } else { "异常" }
$fragColor = if ($fragStatus -eq "正常") { "SUCCESS" } elseif ($fragStatus -eq "警告") { "WARN" } else { "ERROR" }
Write-Log -Level $fragColor -Message "[Redis深度] 内存碎片率: ${fragRatio} [$fragStatus]"
$results += @{
Check = "Redis 内存碎片率"
Status = $fragStatus
Details = "碎片率: ${fragRatio} (< 1.5 正常, 1.5~2.0 警告, > 2.0 异常)"
Value = "${fragRatio}"
Success = ($fragRatio -lt 2.0)
}
}
} catch {
Write-Log -Level "WARN" -Message "[Redis深度] 内存碎片率检测异常: $($_.Exception.Message)"
}
# --- 4. 缓存命中率 ---
try {
$statsCmd = "$redisCliBase INFO Stats 2>/dev/null"
$statsResult = Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $statsCmd
if ($statsResult.ExitCode -eq 0 -and $statsResult.Output) {
$statsOutput = $statsResult.Output -join "`n"
$hits = 0; $misses = 0
if ($statsOutput -match 'keyspace_hits:(\d+)') { $hits = [long]$matches[1] }
if ($statsOutput -match 'keyspace_misses:(\d+)') { $misses = [long]$matches[1] }
$hitRate = 0.0
if (($hits + $misses) -gt 0) {
$hitRate = [math]::Round($hits / ($hits + $misses) * 100, 2)
}
$hitStatus = if ($hitRate -ge 80) { "正常" } elseif ($hitRate -ge 50) { "警告" } else { "异常" }
$hitColor = if ($hitStatus -eq "正常") { "SUCCESS" } elseif ($hitStatus -eq "警告") { "WARN" } else { "ERROR" }
Write-Log -Level $hitColor -Message "[Redis深度] 缓存命中率: ${hitRate}% (命中: $hits / 未命中: $misses) [$hitStatus]"
$results += @{
Check = "Redis 缓存命中率"
Status = $hitStatus
Details = "命中率: ${hitRate}% | 命中: $hits | 未命中: $misses"
Value = "${hitRate}%"
Success = ($hitRate -ge 50)
}
}
} catch {
Write-Log -Level "WARN" -Message "[Redis深度] 缓存命中率检测异常: $($_.Exception.Message)"
}
# --- 5. 主从复制状态 ---
try {
$replCmd = "$redisCliBase INFO Replication 2>/dev/null"
$replResult = Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $replCmd
if ($replResult.ExitCode -eq 0 -and $replResult.Output) {
$replOutput = $replResult.Output -join "`n"
$redisRole = ""; $masterLink = ""
if ($replOutput -match 'role:(\S+)') { $redisRole = $matches[1] }
if ($replOutput -match 'master_link_status:(\S+)') { $masterLink = $matches[1] }
if ($redisRole -eq "master") {
$replStatus = "正常"
$replDetail = "当前为 master 节点"
} elseif ($redisRole -eq "slave") {
$replStatus = if ($masterLink -eq "up") { "正常" } else { "异常" }
$replDetail = "当前为 slave 节点 | master_link_status: $masterLink"
} else {
$replStatus = "正常"
$replDetail = "单机模式"
}
$replColor = if ($replStatus -eq "正常") { "SUCCESS" } else { "ERROR" }
Write-Log -Level $replColor -Message "[Redis深度] 主从复制: $replDetail [$replStatus]"
$results += @{
Check = "Redis 主从复制状态"
Status = $replStatus
Details = $replDetail
Value = $redisRole
Success = ($replStatus -eq "正常")
}
}
} catch {
Write-Log -Level "WARN" -Message "[Redis深度] 主从复制状态检测异常: $($_.Exception.Message)"
}
# --- 6. 键空间统计 ---
try {
$ksCmd = "$redisCliBase INFO Keyspace 2>/dev/null"
$ksResult = Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $ksCmd
if ($ksResult.ExitCode -eq 0 -and $ksResult.Output) {
$ksOutput = $ksResult.Output -join "`n"
$ksLines = $ksOutput -split "`n" | Where-Object { $_ -match '^db\d+:' }
$ksSummary = if ($ksLines.Count -gt 0) { ($ksLines -join ' | ') } else { "无数据" }
Write-Log -Level "INFO" -Message "[Redis深度] 键空间: $ksSummary"
$results += @{
Check = "Redis 键空间统计"
Status = "正常"
Details = "数据库: $($ksLines.Count) 个 | $ksSummary"
Value = "$($ksLines.Count)"
Success = $true
}
}
} catch {
Write-Log -Level "WARN" -Message "[Redis深度] 键空间统计检测异常: $($_.Exception.Message)"
}
Write-Log -Level "INFO" -Message "========== Redis 深度检测完成(共 $($results.Count) 项) =========="
return $results
}
#endregion Redis Deep Check (PRD 2.3)
# ==============================================================================
# 导出模块函数
# ==============================================================================
Export-ModuleMember -Function @(
'Test-MQTTConnection','Test-RedisConnection','Test-MySQLConnection','Test-FastDFSConnection'
'Test-MQTTConnection','Test-RedisConnection','Test-MySQLConnection','Test-FastDFSConnection',
'Test-MySQLDeepCheck','Test-RedisDeepCheck'
)
......@@ -17,7 +17,10 @@ function Show-HealthReport {
[hashtable]$ConsoleResults,
[array]$ContainerInfo,
[array]$AndroidResults,
[array]$MiddlewareResults
[array]$MiddlewareResults,
[array]$SecurityResults = @(),
[array]$MySQLDeepResults = @(),
[array]$RedisDeepResults = @()
)
if (-not $SCRIPT_DIR -or [string]::IsNullOrWhiteSpace($SCRIPT_DIR)) { $SCRIPT_DIR = (Get-Location).Path }
......@@ -247,6 +250,70 @@ function Show-HealthReport {
Write-Host ""
}
# 资源增强检测(inode/只读挂载/TCP/僵尸进程/TOP5)
if ($ResourceResults) {
# inode 使用率
if ($ResourceResults.Inode -and $ResourceResults.Inode.Count -gt 0) {
Write-Host "【inode 使用率】" -ForegroundColor Yellow
$md += "### inode 使用率"
foreach ($inode in $ResourceResults.Inode) {
$inodeIcon = switch ($inode.Status) { "正常" {"✅"} "警告" {"⚠️"} default {"❌"} }
Write-Host " $($inode.MountPoint): $($inode.Used)/$($inode.Total) ($($inode.Percent)%) [$($inode.Status)]"
$md += "- $inodeIcon $($inode.MountPoint): $($inode.Used)/$($inode.Total) ($($inode.Percent)%) [$($inode.Status)]"
}
$md += ""
Write-Host ""
}
# 只读挂载
if ($ResourceResults.ReadOnlyMounts -and $ResourceResults.ReadOnlyMounts.Count -gt 0) {
Write-Host "【只读挂载告警】" -ForegroundColor Red
$md += "### ⚠️ 只读挂载告警"
foreach ($ro in $ResourceResults.ReadOnlyMounts) {
Write-Host " 发现只读挂载: $ro" -ForegroundColor Red
$md += "- ❌ $ro"
}
$md += ""
Write-Host ""
}
# TCP 状态
if ($ResourceResults.TCPStatus -and $ResourceResults.TCPStatus.Count -gt 0) {
Write-Host "【TCP 状态分布】" -ForegroundColor Yellow
$md += "### TCP 状态分布"
foreach ($key in $ResourceResults.TCPStatus.Keys) {
$md += "- ${key}: $($ResourceResults.TCPStatus[$key])"
}
$md += ""
}
# 僵尸进程
if ($ResourceResults.ZombieProcesses -and $ResourceResults.ZombieProcesses.Count -gt 0) {
$zCount = $ResourceResults.ZombieProcesses.Count
$zIcon = if ($zCount -eq 0) { "✅" } else { "⚠️" }
Write-Host " 僵尸进程: $zCount 个" -ForegroundColor $(if ($zCount -gt 0) { "Yellow" } else { "Green" })
$md += "- $zIcon 僵尸进程: $zCount 个"
if ($zCount -gt 0 -and $ResourceResults.ZombieProcesses.List) {
foreach ($z in $ResourceResults.ZombieProcesses.List) {
$md += " - $z"
}
}
$md += ""
}
# TOP5 进程
if ($ResourceResults.TopProcesses -and $ResourceResults.TopProcesses.Count -gt 1) {
Write-Host "【TOP5 进程(按内存)】" -ForegroundColor Yellow
$md += "### TOP5 进程(按内存)"
foreach ($p in $ResourceResults.TopProcesses | Select-Object -Skip 1) {
Write-Host " $p" -ForegroundColor Gray
$md += "- $($p.Trim())"
}
$md += ""
Write-Host ""
}
}
# NTP 服务(异常->修复时间线)
Write-Host "【NTP 服务检测】" -ForegroundColor Yellow
$md += "## NTP 服务检测"
......@@ -390,6 +457,90 @@ function Show-HealthReport {
Write-Host ""
}
# 安全合规检测结果 (PRD 2.1)
if ($SecurityResults -and $SecurityResults.Count -gt 0) {
Write-Host "【安全合规检测】" -ForegroundColor Yellow
$md += "## 安全合规检测"
$md += ""
# 弱密码检测
$md += "### 弱密码检测"
$weakPwdItems = $SecurityResults | Where-Object { $_.Check -match 'MySQL Root|MySQL 空密码|Redis 密码|EMQX 默认|Linux 空密码' }
foreach ($r in $weakPwdItems) {
$icon = if ($r.Status -eq "正常") { "✅" } elseif ($r.Status -eq "跳过") { "ℹ️" } elseif ($r.Status -eq "危险") { "❌" } else { "⚠️" }
Write-Host " $icon $($r.Check): $($r.Status)"
$md += "- $icon $($r.Check): $($r.Status)"
if ($r.Details) {
$detailParts = $r.Details -split ' \| '
foreach ($part in $detailParts) {
$trimmedPart = $part.Trim()
if ($trimmedPart) { $md += " - $trimmedPart" }
}
}
}
# 安全基线扫描
$md += ""
$md += "### 安全基线扫描"
$baselineItems = $SecurityResults | Where-Object { $_.Check -match 'SUID|crontab|SSH 暴力|异常端口' }
foreach ($r in $baselineItems) {
$icon = if ($r.Status -eq "正常") { "✅" } elseif ($r.Status -eq "跳过") { "ℹ️" } elseif ($r.Status -eq "危险") { "❌" } else { "⚠️" }
Write-Host " $icon $($r.Check): $($r.Status)"
$md += "- $icon $($r.Check): $($r.Status)"
if ($r.Details) {
$detailParts = $r.Details -split ' \| '
foreach ($part in $detailParts) {
$trimmedPart = $part.Trim()
if ($trimmedPart) { $md += " - $trimmedPart" }
}
}
}
$md += ""
Write-Host ""
}
# MySQL 深度检测结果 (PRD 2.2)
if ($MySQLDeepResults -and $MySQLDeepResults.Count -gt 0) {
Write-Host "【MySQL 深度检测】" -ForegroundColor Yellow
$md += "## MySQL 深度检测"
$md += ""
foreach ($r in $MySQLDeepResults) {
$icon = if ($r.Status -eq "正常") { "✅" } elseif ($r.Status -eq "警告") { "⚠️" } else { "❌" }
Write-Host " $icon $($r.Check): $($r.Status)"
$md += "- $icon $($r.Check): $($r.Status)"
if ($r.Details) {
$detailParts = $r.Details -split ' \| '
foreach ($part in $detailParts) {
$trimmedPart = $part.Trim()
if ($trimmedPart) { $md += " - $trimmedPart" }
}
}
}
$md += ""
Write-Host ""
}
# Redis 深度检测结果 (PRD 2.3)
if ($RedisDeepResults -and $RedisDeepResults.Count -gt 0) {
Write-Host "【Redis 深度检测】" -ForegroundColor Yellow
$md += "## Redis 深度检测"
$md += ""
foreach ($r in $RedisDeepResults) {
$icon = if ($r.Status -eq "正常") { "✅" } elseif ($r.Status -eq "建议") { "ℹ️" } elseif ($r.Status -eq "警告") { "⚠️" } else { "❌" }
Write-Host " $icon $($r.Check): $($r.Status)"
$md += "- $icon $($r.Check): $($r.Status)"
if ($r.Details) {
$detailParts = $r.Details -split ' \| '
foreach ($part in $detailParts) {
$trimmedPart = $part.Trim()
if ($trimmedPart) { $md += " - $trimmedPart" }
}
}
}
$md += ""
Write-Host ""
}
# 安卓设备自检结果
if ($AndroidResults -and $AndroidResults.Count -gt 0) {
Write-Host "【安卓设备自检】" -ForegroundColor Yellow
......
<#
<#
.SYNOPSIS
服务器资源分析模块
......@@ -478,6 +478,143 @@ printf "%d,%d,%d\n" "$tot_gb" "$use_gb" "$pct"
#endregion 7. 检测系统负载
#region 8. inode 浣跨敤鐜囨娴 (PRD 2.5)
Write-Log -Level "INFO" -Message "妫娴 inode 浣跨敤鐜..."
$inodeCmd = "df -i | grep -E '^/dev/' | awk '{print `$1,`$2,`$3,`$4,`$5,`$6}'"
$inodeResult = Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $inodeCmd
$inodeList = @()
if ($inodeResult.ExitCode -eq 0 -and $inodeResult.Output) {
$inodeLines = $inodeResult.Output -split "`n" | Where-Object { $_ -match '\S' }
foreach ($line in $inodeLines) {
$parts = $line -split '\s+'
if ($parts.Count -ge 6) {
$device = $parts[0]
$iTotal = $parts[1]
$iUsed = $parts[2]
$iFree = $parts[3]
$iPercent = $parts[4] -replace '%', ''
$mountPoint = $parts[5]
# 杩囨护 tmpfs/overlay 绛夎櫄鎷熸枃浠剁郴缁
if ($device -match 'tmpfs|overlay') { continue }
try { $iPercentNum = [int]$iPercent } catch { $iPercentNum = 0 }
$inodeStatus = if ($iPercentNum -lt 70) { "姝e父" } elseif ($iPercentNum -lt 90) { "璀﹀憡" } else { "寮傚父" }
$inodeColor = if ($iPercentNum -lt 70) { "SUCCESS" } elseif ($iPercentNum -lt 90) { "WARN" } else { "ERROR" }
Write-Log -Level $inodeColor -Message " inode $mountPoint : ${iUsed}/${iTotal} (${iPercent}%) [$inodeStatus]"
$inodeList += @{
Device = $device
Total = $iTotal
Used = $iUsed
Percent = $iPercentNum
MountPoint = $mountPoint
Status = $inodeStatus
}
}
}
}
$results.Inode = $inodeList
#endregion 8. inode 浣跨敤鐜囨娴
#region 9. 鍙鎸傝浇妫娴 (PRD 2.5)
Write-Log -Level "INFO" -Message "妫娴嬪彧璇绘寕杞..."
$roCmd = "mount | grep ' ro[, ]' | grep -vE 'proc|sys|dev|cgroup|tmpfs' || echo 'NO_RO'"
$roResult = Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $roCmd
$roList = @()
if ($roResult.ExitCode -eq 0 -and $roResult.Output) {
$roOutput = $roResult.Output -join ""
if ($roOutput -match 'NO_RO') {
Write-Log -Level "SUCCESS" -Message " 鏃犳剰澶栧彧璇绘寕杞"
} else {
$roLines = $roResult.Output -split "`n" | Where-Object { $_ -match '\S' }
foreach ($line in $roLines) {
Write-Log -Level "WARN" -Message " 鍙戠幇鍙鎸傝浇: $line"
$roList += $line
}
}
}
$results.ReadOnlyMounts = $roList
#endregion 9. 鍙鎸傝浇妫娴
#region 10. TCP 鐘舵佸垎甯冩娴 (PRD 2.5)
Write-Log -Level "INFO" -Message "妫娴 TCP 鐘舵佸垎甯..."
$tcpCmd = "netstat -ant 2>/dev/null | awk '{print `$6}' | sort | uniq -c | sort -rn"
$tcpResult = Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $tcpCmd
$tcpStatus = @{}
if ($tcpResult.ExitCode -eq 0 -and $tcpResult.Output) {
$tcpLines = $tcpResult.Output -split "`n" | Where-Object { $_ -match '\S' }
foreach ($line in $tcpLines) {
if ($line -match '^\s*(\d+)\s+(\S+)') {
$count = [int]$matches[1]
$state = $matches[2]
$tcpStatus[$state] = $count
}
}
# 妫鏌ュ紓甯哥姸鎬
$closeWait = if ($tcpStatus['CLOSE_WAIT']) { $tcpStatus['CLOSE_WAIT'] } else { 0 }
$timeWait = if ($tcpStatus['TIME_WAIT']) { $tcpStatus['TIME_WAIT'] } else { 0 }
$tcpWarning = ""
if ($closeWait -gt 100) { $tcpWarning += "CLOSE_WAIT=$closeWait (>100) " }
if ($timeWait -gt 1000) { $tcpWarning += "TIME_WAIT=$timeWait (>1000) " }
if ($tcpWarning) {
Write-Log -Level "WARN" -Message " TCP鐘舵佸紓甯: $tcpWarning"
} else {
Write-Log -Level "SUCCESS" -Message " TCP鐘舵佹甯: CLOSE_WAIT=$closeWait TIME_WAIT=$timeWait"
}
}
$results.TCPStatus = $tcpStatus
#endregion 10. TCP 鐘舵佸垎甯冩娴
#region 11. 鍍靛案杩涚▼鍜 TOP5 妫娴 (PRD 2.5)
Write-Log -Level "INFO" -Message "妫娴嬪兊灏歌繘绋..."
$zombieCmd = "ps aux | awk '`$8~/Z/' | grep -v grep || echo 'NO_ZOMBIE'"
$zombieResult = Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $zombieCmd
$zombieCount = 0
$zombieList = @()
if ($zombieResult.ExitCode -eq 0 -and $zombieResult.Output) {
$zOutput = $zombieResult.Output -join ""
if ($zOutput -match 'NO_ZOMBIE') {
Write-Log -Level "SUCCESS" -Message " 鏃犲兊灏歌繘绋"
} else {
$zLines = $zombieResult.Output -split "`n" | Where-Object { $_ -match '\S' }
$zombieCount = $zLines.Count
Write-Log -Level "WARN" -Message " 鍙戠幇 $zombieCount 涓兊灏歌繘绋"
$zombieList = $zLines
}
}
$results.ZombieProcesses = @{ Count = $zombieCount; List = $zombieList }
# TOP5 杩涚▼锛堟寜鍐呭瓨鍜孋PU锛
Write-Log -Level "INFO" -Message "鑾峰彇 TOP5 杩涚▼..."
$topCmd = "ps aux --sort=-%mem | head -n 6 | awk '{print `$1,`$2,`$3,`$4,`$11}'"
$topResult = Invoke-SSHCommand -HostName $Server.IP -User $Server.User -Pass $Server.Pass -Port $Server.Port -Command $topCmd
$topProcesses = @()
if ($topResult.ExitCode -eq 0 -and $topResult.Output) {
$topLines = $topResult.Output -split "`n" | Where-Object { $_ -match '\S' }
foreach ($line in $topLines) {
$topProcesses += $line.Trim()
}
Write-Log -Level "INFO" -Message " TOP5杩涚▼锛堟寜鍐呭瓨锛:"
foreach ($p in $topProcesses | Select-Object -Skip 1) {
Write-Log -Level "INFO" -Message " $p"
}
}
$results.TopProcesses = $topProcesses
#endregion 11. 鍍靛案杩涚▼鍜 TOP5 妫娴
return $results
}
......
......@@ -111,12 +111,12 @@
| 步骤 | 描述 | PS 版本 | Shell 版本 | 状态 |
|------|------|---------|------------|------|
| 1 | 新建 SecurityCheck.psm1 模块 | 新建文件 | — | [ ] |
| 2 | 实现弱密码检测函数(5个检测项) | SecurityCheck.psm1 | check_server_health.sh 新增 5 个函数 | [ ] |
| 3 | 实现安全基线函数(4个检测项) | SecurityCheck.psm1 | check_server_health.sh 新增 4 个函数 | [ ] |
| 4 | PS 版 Main 函数增加安全检测调用 | check_server_health.ps1 | — | [ ] |
| 5 | Shell 版主流程增加安全检测调用 | — | check_server_health.sh | [ ] |
| 6 | 安全检测结果集成到报告 | Report.psm1 增强 | check_server_health.sh 报告章节增强 | [ ] |
| 1 | 新建 SecurityCheck.psm1 模块 | 新建文件 | — | [x] |
| 2 | 实现弱密码检测函数(5个检测项) | SecurityCheck.psm1 | check_server_health.sh 新增 5 个函数 | [x] |
| 3 | 实现安全基线函数(4个检测项) | SecurityCheck.psm1 | check_server_health.sh 新增 4 个函数 | [x] |
| 4 | PS 版 Main 函数增加安全检测调用 | check_server_health.ps1 | — | [x] |
| 5 | Shell 版主流程增加安全检测调用 | — | check_server_health.sh | [x] |
| 6 | 安全检测结果集成到报告 | Report.psm1 增强 | check_server_health.sh 报告章节增强 | [x] |
---
......@@ -154,14 +154,14 @@
| 步骤 | 描述 | PS 版本 | Shell 版本 | 状态 |
|------|------|---------|------------|------|
| 1 | 新增 `Test-MySQLDeepCheck` 函数 | MiddlewareCheck.psm1 | check_server_health.sh 新增 `test_mysql_deep()` | [ ] |
| 2 | 实现缓冲池命中率检测 | 同上 | 同上 | [ ] |
| 3 | 实现慢查询检测 | 同上 | 同上 | [ ] |
| 4 | 实现连接使用率检测 | 同上 | 同上 | [ ] |
| 5 | 实现主从复制状态检测 | 同上 | 同上 | [ ] |
| 6 | 实现 TOP20 大表检测 | 同上 | 同上 | [ ] |
| 7 | Main/主流程中增加深度检测调用 | check_server_health.ps1 | check_server_health.sh | [ ] |
| 8 | 深度检测结果集成到报告 | Report.psm1 | check_server_health.sh 报告章节 | [ ] |
| 1 | 新增 `Test-MySQLDeepCheck` 函数 | MiddlewareCheck.psm1 | check_server_health.sh 新增 `test_mysql_deep()` | [x] |
| 2 | 实现缓冲池命中率检测 | 同上 | 同上 | [x] |
| 3 | 实现慢查询检测 | 同上 | 同上 | [x] |
| 4 | 实现连接使用率检测 | 同上 | 同上 | [x] |
| 5 | 实现主从复制状态检测 | 同上 | 同上 | [x] |
| 6 | 实现 TOP20 大表检测 | 同上 | 同上 | [x] |
| 7 | Main/主流程中增加深度检测调用 | check_server_health.ps1 | check_server_health.sh | [x] |
| 8 | 深度检测结果集成到报告 | Report.psm1 | check_server_health.sh 报告章节 | [x] |
---
......@@ -199,12 +199,12 @@
| 步骤 | 描述 | PS 版本 | Shell 版本 | 状态 |
|------|------|---------|------------|------|
| 1 | 新增 `Test-RedisDeepCheck` 函数 | MiddlewareCheck.psm1 | check_server_health.sh 新增 `test_redis_deep()` | [ ] |
| 2 | 实现持久化状态检测 | 同上 | 同上 | [ ] |
| 3 | 实现内存碎片率检测 | 同上 | 同上 | [ ] |
| 4 | 实现缓存命中率检测 | 同上 | 同上 | [ ] |
| 5 | 实现主从复制检测 | 同上 | 同上 | [ ] |
| 6 | Main/主流程中增加深度检测调用 | check_server_health.ps1 | check_server_health.sh | [ ] |
| 1 | 新增 `Test-RedisDeepCheck` 函数 | MiddlewareCheck.psm1 | check_server_health.sh 新增 `test_redis_deep()` | [x] |
| 2 | 实现持久化状态检测 | 同上 | 同上 | [x] |
| 3 | 实现内存碎片率检测 | 同上 | 同上 | [x] |
| 4 | 实现缓存命中率检测 | 同上 | 同上 | [x] |
| 5 | 实现主从复制检测 | 同上 | 同上 | [x] |
| 6 | Main/主流程中增加深度检测调用 | check_server_health.ps1 | check_server_health.sh | [x] |
---
......@@ -260,12 +260,12 @@
| 步骤 | 描述 | PS 版本 | Shell 版本 | 状态 |
|------|------|---------|------------|------|
| 1 | 实现 inode 使用率检测 | ServerResourceAnalysis.psm1 | check_server_health.sh | [ ] |
| 2 | 实现只读挂载检测 | 同上 | 同上 | [ ] |
| 3 | 实现关键端口连通性检测 | 同上 | 同上 | [ ] |
| 4 | 实现 TCP 状态分布检测 | 同上 | 同上 | [ ] |
| 5 | 实现僵尸进程和 TOP5 检测 | 同上 | 同上 | [ ] |
| 6 | 新增检测项集成到报告 | Report.psm1 | check_server_health.sh 报告章节 | [ ] |
| 1 | 实现 inode 使用率检测 | ServerResourceAnalysis.psm1 | check_server_health.sh | [x] |
| 2 | 实现只读挂载检测 | 同上 | 同上 | [x] |
| 3 | 实现关键端口连通性检测 | 同上 | 同上 | [x] |
| 4 | 实现 TCP 状态分布检测 | 同上 | 同上 | [x] |
| 5 | 实现僵尸进程和 TOP5 检测 | 同上 | 同上 | [x] |
| 6 | 新增检测项集成到报告 | Report.psm1 | check_server_health.sh 报告章节 | [x] |
---
......@@ -298,9 +298,9 @@
| 步骤 | 描述 | PS 版本 | Shell 版本 | 状态 |
|------|------|---------|------------|------|
| 1 | 实现新平台目录备份逻辑 | DataBackup.psm1 | check_server_health.sh | [ ] |
| 2 | 实现新平台 MySQL 导出 | 同上 | 同上 | [ ] |
| 3 | 测试备份下载流程 | 同上 | 同上 | [ ] |
| 1 | 实现新平台目录备份逻辑 | DataBackup.psm1 | check_server_health.sh | [x] |
| 2 | 实现新平台 MySQL 导出 | 同上 | 同上 | [x] |
| 3 | 测试备份下载流程 | 同上 | 同上 | [x] |
---
......
......@@ -7,7 +7,9 @@
## 功能需求
### 功能目标
**目标:** 对接获取人员列表接口以及调整上传文件接口的抄送人传参和创建人员传参。然后通过交互模式下输入的创建人姓名、抄送人姓名,最终通过接口传参实现。
**目标:** 对接获取人员列表接口以及调整上传文件接口的抄送人传参。然后通过交互模式下输入的抄送人姓名,最终通过接口传参实现。
> **注:** 原需求包含创建人员传参(createuser_name、createuser_id),但创建报告接口 `POST /openclaw/report` 不支持此参数,暂不实现,待接口支持后再补充。
### 需求描述
- 获取人员接口:
......@@ -15,10 +17,10 @@
- 上传文件接口调整:
- 根据[Docs/PRD/自动化生成功能测试报告/ERP对接PRD/PRD_测试列表_测试报告_例子说明.md]第182行。
- createuser_name通过交互模式,用户输入姓名。从获取人员列表中做提取ID。
- copyuserList通过交互模式,用户输入姓名,从获取人员列表中做提取ID,注意参数格式如下:
- ~~createuser_name通过交互模式,用户输入姓名。从获取人员列表中做提取ID。~~ **(暂不支持,创建报告接口无此参数)**
- copyuserList通过交互模式,用户输入姓名(多个姓名以逗号分隔),从获取人员列表中做提取ID,注意参数格式如下:
- "copyuserList": [1, 24, 31]
- 最终在上传文件接口中,将createuser_name、createuser_id、copyuserList作为参数传入。
- 最终在上传文件接口中,将copyuserList作为参数传入。
## 规范文档
- 代码规范: `Docs/PRD/01规范文档/_PRD_规范文档_代码规范.md`
......
# ERP对接优化_计划执行
> 版本:V1.0
> 创建日期:2026-06-07
> 基于文档:`_PRD_ERP对接优化_需求文档.md`
> ERP接口文档:`Docs/PRD/自动化生成功能测试报告/ERP对接PRD/PRD_测试列表_测试报告_例子说明.md`
> 代码路径:`AuxiliaryTool/FunctionalTestReportGeneration/`
> 交付物:
> - `src/erp_uploader.py` — 新增获取人员列表接口、copyuserList参数传入
> - `src/cli.py` — CLI交互增加抄送人姓名输入
> - `src/gui.py` — GUI交互增加抄送人姓名输入
> - `src/config.py` — 新增人员列表接口路径配置
---
## 一、任务概述
### 1.1 背景
当前报告上传到ERP时,`copyuserList` 参数固定为空数组 `[]`(见 `erp_uploader.py:228`)。需要对接 `GET /openclaw/stuff` 人员列表接口,让用户通过交互模式输入抄送人姓名,自动匹配ID后传入创建报告接口。
### 1.2 目标
- [x] 对接获取人员列表接口 `GET /openclaw/stuff`
- [ ] CLI模式下支持用户输入抄送人姓名
- [ ] GUI模式下支持用户输入抄送人姓名
- [ ] 抄送人姓名自动匹配ID并传入 `copyuserList` 参数
- [ ] 创建报告接口 `POST /openclaw/report` 传入 `copyuserList`
### 1.3 暂不实现
> **创建人参数(createuser_name / createuser_id)暂不实现。** 原因:创建报告接口 `POST /openclaw/report` 不支持此参数,待接口支持后再补充。
---
## 二、开发阶段划分
### 阶段一:新增获取人员列表接口(优先级:高)
| 序号 | 任务 | 描述 | 涉及文件 | 预计产出 |
|------|------|------|----------|----------|
| 1.1 | 新增接口路径配置 | 在 `config.py` 中新增 `ERP_STUFF_URL` 常量 | `src/config.py` | 接口路径常量 |
| 1.2 | 实现获取人员列表函数 | 新增 `get_stuff_list()` 函数,调用 `GET /openclaw/stuff` | `src/erp_uploader.py` | 人员列表数据 |
#### 任务1.1:新增接口路径配置
**文件:** `src/config.py`
在 ERP 上传配置区域新增:
```python
# ERP人员列表接口路径
ERP_STUFF_URL = "/openclaw/stuff" # 获取人员列表接口路径
```
#### 任务1.2:实现获取人员列表函数
**文件:** `src/erp_uploader.py`
`upload_image_to_erp` 函数前新增函数:
```python
def get_stuff_list(logger: logging.Logger) -> Optional[list]:
"""
获取ERP系统人员列表(含重试机制)
通过 GET /openclaw/stuff 接口获取所有人员信息,
用于将姓名转换为人员ID。
Args:
logger: 日志记录器
Returns:
人员列表,格式: [{"id": 1, "name": "超管员", "department_id": null}, ...]
失败返回None
"""
url = f'{ERP_BASE_URL}{ERP_STUFF_URL}'
for attempt in range(ERP_MAX_RETRIES):
try:
headers = {'X-Api-Key': ERP_API_KEY}
logger.info(f"=== 获取人员列表请求 ===")
logger.info(f"请求URL: {url}")
resp = requests.get(
url,
headers=headers,
timeout=ERP_REQUEST_TIMEOUT
)
result = resp.json()
if result.get('success') == 1:
stuff_list = result['data']
logger.info(f"✓ 获取人员列表成功,共 {len(stuff_list)} 人")
# 打印人员列表供用户参考
for s in stuff_list:
logger.info(f" - {s['name']} (ID: {s['id']})")
return stuff_list
else:
error_code = result.get('error', 0)
if 400 <= error_code < 500:
logger.error(f"✗ 获取人员列表失败(客户端错误 {error_code}): {result.get('msg')}")
return None
if attempt < ERP_MAX_RETRIES - 1:
logger.warning(f"获取人员列表失败,{ERP_RETRY_INTERVAL}秒后重试({attempt+1}/{ERP_MAX_RETRIES}): {result.get('msg')}")
time.sleep(ERP_RETRY_INTERVAL)
else:
logger.error(f"✗ 获取人员列表失败(已达最大重试次数): {result.get('msg')}")
except requests.exceptions.Timeout:
logger.warning(f"获取人员列表超时,{ERP_RETRY_INTERVAL}秒后重试({attempt+1}/{ERP_MAX_RETRIES})")
if attempt < ERP_MAX_RETRIES - 1:
time.sleep(ERP_RETRY_INTERVAL)
else:
logger.error(f"✗ 获取人员列表超时(已达最大重试次数)")
except Exception as e:
logger.error(f"✗ 获取人员列表异常: {str(e)}")
break
return None
```
同时需要在 `config.py` 导入中增加 `ERP_STUFF_URL`
```python
from src.config import (
# ... 已有导入 ...
ERP_STUFF_URL, # 新增
)
```
---
### 阶段二:新增姓名→ID匹配工具函数(优先级:高)
| 序号 | 任务 | 描述 | 涉及文件 | 预计产出 |
|------|------|------|----------|----------|
| 2.1 | 实现姓名匹配ID函数 | 根据用户输入的姓名列表,从人员列表中匹配对应ID | `src/erp_uploader.py` | 匹配函数 |
#### 任务2.1:姓名匹配ID函数
**文件:** `src/erp_uploader.py`
新增函数:
```python
def match_names_to_ids(names: list, stuff_list: list, logger: logging.Logger) -> list:
"""
将姓名列表匹配为人员ID列表
Args:
names: 用户输入的姓名列表,如 ["黄史恭", "欧阳友平"]
stuff_list: 人员列表数据(来自 get_stuff_list)
logger: 日志记录器
Returns:
匹配成功的人员ID列表,如 [24, 31]
"""
matched_ids = []
not_found_names = []
for name in names:
name = name.strip()
if not name:
continue
# 在人员列表中查找匹配
found = False
for person in stuff_list:
if person['name'] == name:
matched_ids.append(person['id'])
logger.info(f" ✓ 抄送人匹配: {name} → ID: {person['id']}")
found = True
break
if not found:
not_found_names.append(name)
logger.warning(f" ✗ 未找到匹配人员: {name}")
if not_found_names:
logger.warning(f"以下人员未在系统中找到: {', '.join(not_found_names)}")
return matched_ids
```
---
### 阶段三:CLI交互模式改造(优先级:高)
| 序号 | 任务 | 描述 | 涉及文件 | 预计产出 |
|------|------|------|----------|----------|
| 3.1 | 新增抄送人输入函数 | CLI模式下让用户输入抄送人姓名 | `src/erp_uploader.py` | 交互输入函数 |
| 3.2 | 修改CLI上传确认流程 | 在 `ask_upload_confirmation_cli` 中增加抄送人输入环节 | `src/erp_uploader.py` | 更新确认流程 |
| 3.3 | 修改创建报告函数 | `create_report_in_erp` 接受 `copyuserList` 参数 | `src/erp_uploader.py` | 参数透传 |
| 3.4 | 修改CLI调用入口 | `cli.py` 中透传抄送人参数 | `src/cli.py` | 调用链路更新 |
#### 任务3.1:新增CLI抄送人输入函数
**文件:** `src/erp_uploader.py`
`ask_upload_confirmation_cli` 函数中增加抄送人输入环节。
#### 任务3.2:修改 `ask_upload_confirmation_cli`
**文件:** `src/erp_uploader.py`
修改现有函数,返回值增加 `copyuser_ids`
```python
def ask_upload_confirmation_cli(report_path: str) -> Tuple[bool, Optional[int], Optional[list]]:
"""
控制台交互:询问用户是否上传报告到ERP,输入测试单ID和抄送人
Args:
report_path: 报告文件路径
Returns:
元组 (用户选择, 测试单ID, 抄送人ID列表)
- (True, int, list) 用户确认上传
- (False, None, None) 用户选择不上传
"""
while True:
print(f"\n{'='*50}")
print(f"是否将报告上传到ERP测试单?")
print(f"{'='*50}")
print(f"报告文件: {report_path}")
choice = input("请输入 Y/N (默认=N): ").strip().upper()
if not choice:
choice = 'N'
if choice == 'Y':
# 输入测试单ID
testing_id = None
while True:
id_input = input(f"请输入测试单ID (默认={ERP_DEVELOPTESTING_ID}): ").strip()
if not id_input:
testing_id = ERP_DEVELOPTESTING_ID
break
try:
testing_id = int(id_input)
if testing_id > 0:
break
else:
print("测试单ID必须为正整数,请重新输入")
except ValueError:
print("输入无效,请输入数字")
# 输入抄送人
copyuser_ids = None
copyuser_input = input("请输入抄送人姓名(多人用逗号分隔,直接回车跳过): ").strip()
if copyuser_input:
logger = setup_logger()
# 获取人员列表
stuff_list = get_stuff_list(logger)
if stuff_list:
names = [n.strip() for n in copyuser_input.split(',')]
copyuser_ids = match_names_to_ids(names, stuff_list, logger)
if copyuser_ids:
print(f" 匹配到抄送人ID: {copyuser_ids}")
else:
print(" 未匹配到任何抄送人,将以空列表提交")
copyuser_ids = []
else:
print(" 获取人员列表失败,将以空列表提交")
copyuser_ids = []
return True, testing_id, copyuser_ids
elif choice == 'N':
return False, None, None
else:
print("输入错误,请输入 Y 或 N")
```
#### 任务3.3:修改 `create_report_in_erp` 函数签名
**文件:** `src/erp_uploader.py`
增加 `copyuser_list` 参数:
```python
def create_report_in_erp(
content: str,
logger: logging.Logger,
developtesting_id: int = None,
copyuser_list: Optional[list] = None # 新增参数
) -> Optional[int]:
"""
在ERP中创建测试报告(含重试机制)
Args:
content: HTML格式的报告内容
logger: 日志记录器
developtesting_id: 测试单ID(可选,默认使用config中的配置)
copyuser_list: 抄送人ID列表(可选,默认为空列表)
Returns:
报告ID,失败返回None
"""
# 如果没有指定测试单ID,使用config中的默认值
if developtesting_id is None:
developtesting_id = ERP_DEVELOPTESTING_ID
# 如果没有指定抄送人列表,默认为空
if copyuser_list is None:
copyuser_list = []
url = f'{ERP_BASE_URL}{ERP_CREATE_REPORT_URL}'
for attempt in range(ERP_MAX_RETRIES):
try:
# 准备请求参数(copyuser_list 替换原来的空列表)
data = {
'developtesting_id': developtesting_id,
'type_id': ERP_TYPE_ID,
'content': content,
'descript': '',
'copyuserList': copyuser_list # 使用传入的抄送人列表
}
# ... 后续代码不变 ...
```
#### 任务3.4:修改 `upload_report_to_erp` 函数签名
**文件:** `src/erp_uploader.py`
透传 `copyuser_list` 参数:
```python
def upload_report_to_erp(
file_path: str,
logger: logging.Logger = None,
developtesting_id: int = None,
copyuser_list: Optional[list] = None # 新增参数
) -> bool:
"""
上传报告到ERP的完整流程
Args:
file_path: Word报告文件路径
logger: 日志记录器(可选,默认创建新的logger)
developtesting_id: 测试单ID(可选,默认使用config中的配置)
copyuser_list: 抄送人ID列表(可选,默认为空列表)
Returns:
是否上传成功
"""
# ... 省略不变的代码 ...
# 2. 创建ERP报告(透传 copyuser_list)
logger.info(f"[步骤2/2] 创建ERP测试报告")
report_id = create_report_in_erp(
html_content,
logger,
developtesting_id=developtesting_id,
copyuser_list=copyuser_list # 透传
)
# ... 后续代码不变 ...
```
#### 任务3.5:修改 `cli.py` 调用入口
**文件:** `src/cli.py`
修改 ERP 上传调用部分(约355-368行),适配新的返回值和参数:
```python
# ===== ERP上传功能 =====
docx_reports = [r for r in generated_reports if r.endswith('.docx')]
if docx_reports:
try:
from src.erp_uploader import ask_upload_confirmation_cli, upload_report_to_erp, setup_logger
logger = setup_logger()
for report_path in docx_reports:
# 询问用户是否上传,获取测试单ID和抄送人列表
confirmed, testing_id, copyuser_ids = ask_upload_confirmation_cli(report_path)
if confirmed:
print(f"\n正在上传报告到ERP(测试单ID: {testing_id})...")
success = upload_report_to_erp(
report_path,
logger,
developtesting_id=testing_id,
copyuser_list=copyuser_ids # 透传抄送人列表
)
if success:
print(f" [OK] 报告已成功上传到ERP(测试单ID: {testing_id})")
else:
print(f" [X] 报告上传失败,请查看日志")
else:
print(f" [!] 跳过上传: {report_path}")
except ImportError as e:
print(f" [!] ERP上传功能不可用: {str(e)}")
except Exception as e:
print(f" [X] ERP上传功能异常: {str(e)}")
# ===== ERP上传功能结束 =====
```
---
### 阶段四:GUI交互模式改造(优先级:高)
| 序号 | 任务 | 描述 | 涉及文件 | 预计产出 |
|------|------|------|----------|----------|
| 4.1 | 修改GUI上传确认对话框 | 增加"抄送人"输入框,展示人员列表 | `src/gui.py` | 更新对话框 |
| 4.2 | 修改GUI上传调用 | 透传 `copyuser_list` 参数 | `src/gui.py` | 参数透传 |
#### 任务4.1:修改 `_show_upload_dialog` 对话框
**文件:** `src/gui.py`
在现有对话框中增加抄送人输入区域:
- 在测试单ID输入框下方增加"抄送人"输入框
- 输入框旁增加"获取人员列表"按钮,点击后调用 `get_stuff_list()` 并在下拉区域展示可抄送人员
- 支持手动输入姓名(逗号分隔)
- 返回值从 `(bool, int)` 变为 `(bool, int, list)`
#### 任务4.2:修改 GUI 上传调用链
**文件:** `src/gui.py`
涉及以下函数的签名和调用修改:
1. `_show_upload_dialog` → 返回 `(confirmed, testing_id, copyuser_ids)`
2. `_ask_upload_to_erp` → 透传 `copyuser_ids`
3. `_upload_to_erp` → 透传 `copyuser_list``upload_report_to_erp`
4. `_upload_to_erp_thread` → 透传 `copyuser_list`
---
## 三、文件结构
```
AuxiliaryTool/FunctionalTestReportGeneration/
├── src/
│ ├── config.py # [修改] 新增 ERP_STUFF_URL 常量
│ ├── erp_uploader.py # [修改] 新增获取人员列表、姓名匹配、copyuserList参数透传
│ ├── cli.py # [修改] CLI交互增加抄送人输入
│ └── gui.py # [修改] GUI对话框增加抄送人输入
├── config/
│ └── ...
└── ...
```
---
## 四、依赖与规范
### 4.1 依赖接口
| 接口 | 方法 | 路径 | 用途 |
|------|------|------|------|
| 获取人员列表 | GET | `/openclaw/stuff` | 获取所有人员ID和姓名 |
| 创建测试报告 | POST | `/openclaw/report` | 上传报告(传入copyuserList) |
### 4.2 API响应格式
获取人员列表响应示例:
```json
{
"success": 1,
"data": [
{"id": 1, "name": "超管员", "department_id": null},
{"id": 20, "name": "郑晓兵", "department_id": 94020884},
{"id": 24, "name": "黄史恭", "department_id": 596543063}
]
}
```
创建报告时 copyuserList 参数格式:
```json
{
"copyuserList": [1, 24, 31]
}
```
### 4.3 编码规范
- 遵循 `Docs/PRD/01规范文档/_PRD_规范文档_代码规范.md`
- 所有代码必须有中文注释
- 新增函数需有完整的 docstring
- 保持与现有代码风格一致(日志格式、错误处理模式、重试机制)
---
## 五、验收清单
### 5.1 功能验收
- [ ] CLI模式:上传确认时可以输入抄送人姓名(逗号分隔多个)
- [ ] CLI模式:输入的姓名正确匹配为人员ID
- [ ] CLI模式:未匹配的姓名有明确的警告提示
- [ ] CLI模式:不输入抄送人时(直接回车)跳过,不影响原有流程
- [ ] GUI模式:上传确认对话框新增抄送人输入框
- [ ] GUI模式:可通过按钮获取人员列表作为参考
- [ ] 创建报告接口正确传入 `copyuserList` 参数
### 5.2 兼容性验收
- [ ] 不输入抄送人时,`copyuserList` 为空数组 `[]`,行为与修改前一致
- [ ] 不修改测试单ID输入逻辑
- [ ] 不修改图片上传逻辑
- [ ] 不修改Word转HTML逻辑
### 5.3 异常处理验收
- [ ] 获取人员列表接口失败时,提示用户并可继续上传(以空列表提交)
- [ ] 输入不存在的姓名时,警告提示但不会中断流程
- [ ] 网络超时等异常遵循现有的重试机制
---
## 六、进度跟踪
| 阶段 | 任务 | 状态 | 完成时间 | 备注 |
|------|------|------|----------|------|
| 一 | 新增获取人员列表接口 | [ ] | | |
| 二 | 姓名匹配ID工具函数 | [ ] | | |
| 三 | CLI交互模式改造 | [ ] | | |
| 四 | GUI交互模式改造 | [ ] | | |
---
## 七、重要注意事项
1. **copyuserList 是覆盖语义**:更新时会完全覆盖原有列表,非追加
2. **API Key 权限**:需确保 API Key 已添加 `stuff` 接口权限
3. **人员列表可能变化**:每次上传都应实时获取最新人员列表,不缓存
4. **创建人参数暂不实现**`createuser_name` / `createuser_id` 不在创建报告接口参数中,标注为暂不支持
---
*本文档基于 PRD 自动生成,如有疑问请参考原需求文档。*
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论