import os, sys, argparse, json, stat, glob
import shutil
import subprocess
import logging
import yaml
import re
import socket
import fcntl
import struct
from flask import jsonify, send_file, Response, request
import requests
import hashlib
import inflection
import time
import requests
import tarfile
import tempfile
from contextlib import closing
import xml.etree.ElementTree as ET
from typing import Dict, Any, Optional
from ruamel.yaml import YAML
from ruamel.yaml.comments import CommentedMap
from datetime import datetime
from ruamel.yaml import YAML
from cryptography.hazmat.primitives import hashes
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.backends import default_backend
from typing import List, Dict, Optional, Union, Tuple
import base64
import errno
import uuid
import psutil
from pathlib import Path

# region 全局变量等
# 调整路径获取逻辑，支持打包环境和外部加载
if getattr(sys, 'frozen', False):
    # 打包环境：获取可执行文件所在目录
    config_tool_dir = os.path.dirname(os.path.abspath(sys.executable))
else:
    # 开发环境：获取当前脚本所在目录
    config_tool_dir = os.path.dirname(os.path.abspath(__file__))

print(f'config_tool_dir: {config_tool_dir}')
opt_dir = "/opt/"
rms_dir = os.path.join(opt_dir, "sysb")
sysb_metadata_dir = os.path.join(rms_dir, "metadata")
home_dir = os.path.dirname(rms_dir)
project_dir = os.path.join(rms_dir, "project")
script_dir = os.path.join(rms_dir, "scripts")
env_path = os.path.join(script_dir, ".env")
config_pass_path = os.path.join(config_tool_dir, "config_pass.json")
sysb_core_metadata_dir = os.path.join(sysb_metadata_dir, "sysb_core")
install_status_path = os.path.join(rms_dir, "install_status")


#TODO: 待确认这里的全局变量
temp_dir = os.path.join(opt_dir, "sandbox")
# 全局配置（可根据实际环境调整路径）
DEPENDENCY_SCRIPT_PATH = "/opt/sandbox/sysb-deployment/scripts/INSTALL-DEPANDENCES.sh"  # 依赖安装脚本路径
PULL_SCRIPT_PATH = "/opt/sandbox/sysb-deployment/scripts/pull_from_cloud.py"    # 拉取资源脚本路径
EXTRACT_SCRIPT_PATH = "/opt/sandbox/sysb-deployment/scripts/extract_modules.py"  # 解压脚本路径
METADATA_ROOT = "/opt/sandbox/sysb-deployment/metadata"                        # metadata根目录
MODULES_ROOT = "/opt/sysb/modules"  # 模块安装根目录（与extract_modules.py一致）
THIRD_PARTY_ROOT = os.path.join(rms_dir, "modules", "third_party_app")
MODULE_NAME_MAPPING = {
    # 显示名称 → metadata下的实际目录名（关键映射）
    "Console": "robot_console",
    "System B": "sysb_core",
    "DWdeploy": "dwdeploy_lite"
}
# 维护模式相关路径
SNAPSHOT_DIR = "/opt/sandbox/snapshots"  # 存储运行项目快照的目录
LAST_RUNNING_PROJECT_FILE = os.path.join(SNAPSHOT_DIR, "last_running_project.txt")  # 记录当前运行项目的文件
MAINTENANCE_FLAG_FILE = os.path.join(SNAPSHOT_DIR, "maintenance_done.flag")  # 维护完成标记文件（避免重复维护）
STOP_PROJECT_SCRIPT = "/opt/sandbox/sysb-deployment/scripts/stop_project.py"  # stop_project.py 脚本路径（必须修改为实际路径）
SYSTEM_B_SCRIPT = "/opt/sandbox/sysb-deployment/scripts/update-sysb-core.py"  # System B专用脚本
UPDATE_PROJECT_PY = "/opt/sysb/scripts/update_project.py"
SOFTWARE_CIPHER_PATH = os.path.join(script_dir, "software_cipher.py")

logger = logging.getLogger('config_tool_logger')
ftp_port = 30021

REQUEST_FAIL = 500

global extract_info

# 配置文件路径
ENCRYPTED_CONFIG_PATH = os.path.join(config_tool_dir, "software.enc")  # 加密的配置文件
PLAINTEXT_CONFIG_PATH = os.path.join(config_tool_dir, "software.yaml")  # 明文配置文件（仅开发环境用）

# 检查更新时的临时目录（用于版本比对）
CHECK_UPDATE_DIR = "/opt/sandbox/check_update"
# 最终部署目录（其他脚本依赖此路径）
FINAL_DEPLOYMENT_DIR = "/opt/sandbox/sysb-deployment"
# 兼容旧代码的变量（指向最终部署目录）
sandbox_extracted_dir = FINAL_DEPLOYMENT_DIR

SCRIPT_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = SCRIPT_DIR.parent / "project" 
sys.path.append(str(SCRIPT_DIR))
try:
    from sysb_manager import SysbManager
except ImportError as e:
    print(f"Import Error: {e}")
    sys.exit(1)

# endregion 全局变量等

def dict_to_camel(d):
    """
    递归将字典中的蛇形键转换为驼峰键，支持嵌套结构
    
    Args:
        d: 要转换的字典或列表
        
    Returns:
        转换后的新字典/列表，保持原数据结构
    """
    # 处理字典类型
    if isinstance(d, dict):
        new_dict = {}
        for key, value in d.items():
            # 转换键为驼峰格式
            camel_key = inflection.camelize(key, False)
            # 递归处理值
            new_dict[camel_key] = dict_to_camel(value)
        return new_dict
    
    # 处理列表类型
    elif isinstance(d, list):
        return [dict_to_camel(item) for item in d]
    
    # 其他类型直接返回
    else:
        return d

# region 登录

def read_install_status():
    """
    读取 /opt/sysb/install_status 文件的内容，返回 JSON 格式的数据
    
    Returns:
        dict: 包含 install_status 内容的字典
    """
    import fcntl
    try:
        if os.path.exists(install_status_path):
            with open(install_status_path, 'r', encoding='utf-8') as f:
                # 添加共享锁
                fcntl.flock(f, fcntl.LOCK_SH)
                try:
                    content = f.read().strip()
                    if content:
                        return json.loads(content)
                finally:
                    # 释放锁
                    fcntl.flock(f, fcntl.LOCK_UN)
    except Exception as e:
        logger.error(f"读取 install_status 文件失败: {str(e)}")
    return {}


def write_install_status(data):
    """
    将 JSON 格式的数据写入 /opt/sysb/install_status 文件
    
    Args:
        data (dict): 要写入的 JSON 数据
    
    Returns:
        bool: 写入成功返回 True，失败返回 False
    """
    import fcntl
    try:
        # 确保目录存在
        os.makedirs(os.path.dirname(install_status_path), exist_ok=True)
        with open(install_status_path, 'w', encoding='utf-8') as f:
            # 添加独占锁
            fcntl.flock(f, fcntl.LOCK_EX)
            try:
                json.dump(data, f, indent=2, ensure_ascii=False)
            finally:
                # 释放锁
                fcntl.flock(f, fcntl.LOCK_UN)
        return True
    except Exception as e:
        logger.error(f"写入 install_status 文件失败: {str(e)}")
        return False

def update_install_status(
    stage,  # 整体阶段：download/update/cleanup/restart
    sub_stage,  # 子阶段：pending/downloading/extracting/pre_script_running/running/success/failed
    module_name,  # 当前处理的模块名
    progress,  # 整体进度（0-100）
    task_id="",  # 任务ID（必填）
    detail="",  # 详细描述
    error_msg="",  # 错误信息（失败时填充）
    system_type=""
):
    """
    统一更新安装状态文件的格式
    """
    status_data = {
        "taskId": task_id,  # 核心新增：关联任务ID
        "systemType": system_type,  # 系统类型：sysb_core/robot_console
        "stage": stage,  # download(下载)/update(安装)/cleanup(清理)/restart(重启)
        "subStage": sub_stage,  # 子状态（复用TASK_STATUS）
        "moduleName": module_name,
        "progress": progress,  # 0-100
        "detail": detail,
        "errorMsg": error_msg,
        "updateTime": time.strftime("%Y-%m-%d %H:%M:%S")
    }
    write_install_status(status_data)

def login_check(param_dict):
    username = param_dict.get("username")
    password_input = param_dict.get("password")
    logger.info(f"login_check的参数 username={username}, password=*****")
    
    # 读取存储的用户密码
    try:
        with open(config_pass_path, 'r') as file:
            user_data = json.load(file)
    except (FileNotFoundError, json.JSONDecodeError):
        logger.error(f"密码文件不存在或格式错误: {config_pass_path}")
        return False
    
    # 检查用户是否存在
    if username not in user_data:
        logger.info(f"用户不存在: {username}")
        return False
    
    # 获取存储的哈希密码
    stored_hash = user_data[username]
    
    # 验证密码（假设存储的是哈希值，这里使用简单示例）
    # 实际生产环境应使用更安全的哈希算法（如bcrypt）
    input_hash = hashlib.sha256(password_input.encode('utf-8')).hexdigest()
    
    if input_hash == stored_hash:
        logger.info(f"用户 {username} 验证成功")
        return True
    else:
        logger.info(f"用户 {username} 密码错误")
        return False

# 新增：用于设置用户密码的函数（仅示例，实际应通过安全方式调用）
def set_user_password(param_dict):
    username = param_dict.get("username")
    password = param_dict.get("password")
    logger.info(f"set_user_password的参数 username={username}, password=*****")
    """设置用户密码（将密码哈希后存储）"""
    # 计算密码哈希
    password_hash = hashlib.sha256(password.encode('utf-8')).hexdigest()
    
    # 读取现有用户数据
    try:
        with open(config_pass_path, 'r') as file:
            user_data = json.load(file)
    except (FileNotFoundError, json.JSONDecodeError):
        user_data = {}
    
    # 更新用户密码
    user_data[username] = password_hash
    
    # 保存到文件
    with open(config_pass_path, 'w') as file:
        json.dump(user_data, file, indent=2)
    
    logger.info(f"用户 {username} 密码已更新")


def login_update(param_dict):
    """
    更新用户密码
    参数:
        param_dict: 包含用户名、旧密码和新密码的字典
            - username: 用户名
            - old_password: 旧密码
            - new_password: 新密码
    返回:
        True: 密码更新成功
        False: 密码更新失败（如旧密码错误、用户不存在）
    """
    username = param_dict.get("username")
    old_password = param_dict.get("old_password")
    new_password = param_dict.get("new_password")
    
    logger.info(f"尝试更新用户 {username} 的密码")
    
    # 验证旧密码是否正确
    if not login_check({"username": username, "password": old_password}):
        logger.info(f"用户 {username} 的旧密码验证失败")
        return False
    
    # 更新密码
    try:
        # 读取现有用户数据
        with open(config_pass_path, 'r') as file:
            user_data = json.load(file)
        
        # 计算新密码的哈希值
        new_hash = hashlib.sha256(new_password.encode('utf-8')).hexdigest()
        
        # 更新用户密码
        user_data[username] = new_hash
        
        # 保存到文件
        with open(config_pass_path, 'w') as file:
            json.dump(user_data, file, indent=2)
        
        logger.info(f"用户 {username} 的密码已成功更新")
        return True
    
    except (FileNotFoundError, json.JSONDecodeError) as e:
        logger.error(f"读取密码文件失败: {e}")
        return False
    except Exception as e:
        logger.error(f"更新密码失败: {e}")
        return False
    
    return False

# endregion 登录

# region 基本信息
def get_robot_ip():
    try:
        result = subprocess.run(
            ["ifconfig"], 
            capture_output=True, 
            text=True
        )
        output = result.stdout

        ip_patterns = ["192.168.1", "192.168.144", "192.168.100", "10.21.31", "192.168"]
        for pattern in ip_patterns:
            for line in output.splitlines():
                if pattern in line:
                    return line.strip().split()[1]
        return None  # 未找到匹配的IP
    
    except Exception as e:
        print(f"Error: {e}")
        return None

def parse_camera_params():
    """解析camera_params.yaml"""
    
    # 构建配置文件路径
    config_path = os.path.join(
        sysb_core_metadata_dir, 
        "config", "hardware", "camera", 
        "camera_ros_interface", 
        "camera_params.yaml"
    )
    
    # 检查文件是否存在
    if not os.path.exists(config_path):
        raise FileNotFoundError(f"配置文件不存在: {config_path}")
    
    # 读取并解析YAML文件
    with open(config_path, 'r', encoding='utf-8') as f:
        config = yaml.safe_load(f)
    
    # 提取所需参数
    ptz_config = config.get('PTZ_CAM', {}).get('ros__parameters', {})
    login_ip = ptz_config.get('login_ip')

    camera_info = {
        'user': ptz_config.get('user'),
        'password': ptz_config.get('password'),
        'login_ip': login_ip
    }
    
    # 验证必要参数
    for key, value in camera_info.items():
        if value is None:
            raise ValueError(f"配置文件中缺少必要的参数: {key}")
    
    return camera_info

def transfer_camera_params_names(param_dict):
    return {
        "user": param_dict.get("camera_user"),
        "password": param_dict.get("camera_password"),
        "login_ip": param_dict.get("camera_ip")
    }

def update_camera_params(param_dict, folder_dir):
    """
    遍历更新文件夹下所有子文件夹的camera_params.yaml配置文件
    
    参数:
        param_dict (dict): 包含待更新参数的字典，如 {'user': 'admin', 'password': 'newpass', 'login_ip': '192.168.1.1'}
        folder_dir (str): sysb_core_metadata_dir或project_dir层级的目录路径
    """
    # 检查目录是否存在
    if not os.path.exists(folder_dir):
        raise FileNotFoundError(f"指定的目录不存在: {folder_dir}")
    
    for item in os.listdir(folder_dir):
        item_path = os.path.join(folder_dir, item)
        if os.path.isdir(item_path):
            update_camera_config_file(item_path, param_dict)

def update_camera_config_file(folder_path, param_dict):
    """
    更新指定目录下的camera_params.yaml配置文件
    
    参数:
        param_dict (dict): 包含待更新参数的字典，如 {'user': 'admin', 'password': 'newpass', 'login_ip': '192.168.1.1'}
        folder_path (str): v2.0.0或p20250509_v2.0.1层级的目录路径
    """
    # 构建配置文件路径
    config_path = os.path.join(
        folder_path, 
        "config", "hardware", "camera", 
        "camera_ros_interface", 
        "camera_params.yaml"
    )

    # 检查文件是否存在
    if not os.path.exists(config_path):
        # raise FileNotFoundError(f"配置文件不存在: {config_path}")
        logger.info(f"配置文件不存在: {config_path}")
        return False

    # 配置YAML解析器
    yaml = YAML()
    yaml.preserve_quotes = True
    yaml.indent(mapping=2, sequence=4, offset=2)
    yaml.width = 4096  # 设置宽以避免长行被折叠

    # 读取原始配置
    with open(config_path, 'r', encoding='utf-8') as f:
        config = yaml.load(f)

    # 更新配置
    ptz_config = config.get('PTZ_CAM', {}).get('ros__parameters', {})
    
    # 只更新param_dict中存在的键
    for key, value in param_dict.items():
        print(f"key: {key}, value: {value}")
        if key == "user" or key == "password":
            ptz_config[key] = value
        elif key == "login_ip":
            ptz_config["login_ip"] = value
            try:
                # 拆分IP地址并转换最后一位为整数
                ip_parts = value.split('.')
                if len(ip_parts) == 4:
                    last_octet = int(ip_parts[-1])
                    # 计算新的最后一位
                    new_last_octet = last_octet + 1
                    # 重新组合IP地址
                    infrared_ip = f"{ip_parts[0]}.{ip_parts[1]}.{ip_parts[2]}.{new_last_octet}"
                    ptz_config["login_ip2"] = infrared_ip
                else:
                    # 如果IP格式不正确，使用默认值
                    ptz_config["login_ip2"] = "192.168.144.203"
            except (ValueError, IndexError) as e:
                # 处理可能的异常，如无法转换为整数
                print(f"警告: IP地址格式异常，使用默认值: {e}")
                ptz_config["login_ip2"] = "192.168.144.203"
        else:
            print(f"警告: 忽略未知/为空参数 '{key}'")

    # 写入更新后的配置
    with open(config_path, 'w', encoding='utf-8') as f:
        yaml.dump(config, f)

    print(f"配置文件已更新: {config_path}")
    return True

def get_robot_name_type():
    robot_name = None
    robot_type = None
    with open(env_path, 'r') as file:
        for line in file:
            line = line.strip()
            # print("line---", line)
            if line.startswith('ROBOT_NAME='):
                robot_name = line.split('=', 1)[1].strip()
            elif line.startswith('ROBOT_TYPE='):
                robot_type = line.split('=', 1)[1].strip()
    return robot_name, robot_type

def get_basic_info():
    robot_name, robot_type = get_robot_name_type()
    robot_ip = get_robot_ip()
    camera_info = parse_camera_params()
    return  {
        "robotName": robot_name,
        "robotType": robot_type,
        "robotIp": robot_ip,
        "cameraUser": camera_info.get('user'),
        "cameraPassword": camera_info.get('password'),
        "cameraIp": camera_info.get('login_ip')
    }

def update_basic_info(param_dict):
    current_project = get_project_info().get("projectName")
    if current_project != "":
        raise Exception("请先停止运行项目")
    logger.info("更新本体配置")
    """
    更新 .env 文件中的字段，包括项目、后端类型、机器人名称和类型
    """
    new_robot_name = param_dict.get("robot_name")
    new_robot_type = param_dict.get("robot_type")
    try:
        with open(env_path, 'r') as f:
            lines = f.readlines()
        with open(env_path, 'w') as f:
            for line in lines:
                if line.startswith('ROBOT_NAME=') and new_robot_name != None:
                    f.write(f'ROBOT_NAME={new_robot_name}\n')
                    print(f'已更新 .env 文件: ROBOT_NAME={new_robot_name}')
                elif line.startswith('ROBOT_TYPE=') and new_robot_type != None:
                    f.write(f'ROBOT_TYPE={new_robot_type}\n')
                    print(f'已更新 .env 文件: ROBOT_TYPE={new_robot_type}')
                else:
                    f.write(line)
    except FileNotFoundError:
        print(f".env 文件不存在，无法更新")
    except Exception as e:
        print(f"更新 .env 文件失败: {e}")
    
    camera_params = transfer_camera_params_names(param_dict)
    print(f"camera_params: {camera_params}")
    update_camera_params(camera_params, sysb_metadata_dir)
    update_camera_params(camera_params, project_dir)
    return True

def alter_lidar_states_in_response(response):
    data = response.get("data")
    whole_lidar_state = data.get("whole_lidar_state")
    lidar_states = data.get("lidar_states")
    if whole_lidar_state and lidar_states:
        whole_lidar_state, lidar_states_w_name = lidar_states_parser(whole_lidar_state, lidar_states)
        data["whole_lidar_state"] = whole_lidar_state
        data["lidar_states"] = lidar_states_w_name
    return response

def lidar_states_parser(whole_lidar_state, lidar_states):
    _, robot_type = get_robot_name_type()
    if robot_type == 'x30':
        lidar_states_w_name = [
            {
                "name": "前上",
                "status": lidar_states[0]
            },
            {
                "name": "前下",
                "status": lidar_states[1]
            },
            {
                "name": "后上",
                "status": lidar_states[2]
            },
            {
                "name": "后下",
                "status": lidar_states[3]
            }
        ]
    elif robot_type == 'saturn_is':
        lidar_states_w_name = [
            {
                "name": "前上",
                "status": lidar_states[0]
            },
            {
                "name": "前下",
                "status": lidar_states[1]
            }
        ]
    else:
        lidar_states_w_name = []
        whole_lidar_state = None
    return whole_lidar_state, lidar_states_w_name

# endregion 基本信息

# region 能力模块配置

def update_env_parameter(file_path: str, key: str, value: any) -> bool:
    """更新环境变量文件中的参数值"""
    try:
        # 读取文件内容
        with open(file_path, 'r', encoding='utf-8') as f:
            lines = f.readlines()
        
        # 查找并更新指定的键
        key_found = False
        for i, line in enumerate(lines):
            line_stripped = line.strip()
            # 跳过注释行和空行
            if line_stripped.startswith('#') or not line_stripped:
                continue
            # 查找键值对
            if '=' in line_stripped:
                k, v = line_stripped.split('=', 1)
                if k.strip() == key:
                    # 更新值，保持原有的格式
                    lines[i] = f"{key}={value}\n"
                    key_found = True
                    break
        
        # 如果没找到键，在文件末尾添加
        if not key_found:
            lines.append(f"{key}={value}\n")
        
        # 写回文件
        with open(file_path, 'w', encoding='utf-8') as f:
            f.writelines(lines)
        
        logger.info(f"成功更新 {file_path} 中的 {key}={value}")
        return True
    
    except Exception as e:
        print(f"更新环境变量参数失败: {str(e)}")
        logger.error(f"更新环境变量参数失败: {str(e)}")
        return False
    
def extract_task_point_ignore_orientation(xml_content):
    # 解析XML内容
    root = ET.fromstring(xml_content)
    
    # 查找BehaviorTree节点
    behavior_tree = root.find(".//BehaviorTree[@ID='first_init']")
    if behavior_tree is not None:
        # 查找所有SetBlackboard和SetTypeBlackboard节点
        for node in behavior_tree.findall(".//*[@output_key]"):
            output_key = node.get("output_key")
            value = node.get("value")
            
            if output_key and value:
                # 特殊处理task_point_ignore_orientation，根据type转换类型
                if output_key == "task_point_ignore_orientation":
                    task_point_ignore_orientation = value
    
    return task_point_ignore_orientation

def extract_decision_first_init_tree(xml_content: str, target_fields) -> Dict[str, Any]: 
    result = {field: None for field in target_fields}  # 初始化结果字典
    
    try:
        root = ET.fromstring(xml_content)
        behavior_tree = root.find(".//BehaviorTree[@ID='first_init']")
        
        if behavior_tree is None:
            return result  # 未找到行为树，返回空结果
        
        # 遍历所有SetTypeBlackboard节点
        for node in behavior_tree.findall(".//SetTypeBlackboard"):
            output_key = node.get("output_key")
            value = node.get("value")
            type_value = node.get("type", "").lower()
            
            # 只处理目标字段
            if output_key in target_fields:
                # 根据类型转换值
                if type_value == "bool" or type_value == "boolean":
                    result[output_key] = value.lower() == "true"
                elif type_value == "uint8" or type_value == "int" or type_value == "integer":
                    result[output_key] = int(value)
                elif type_value == "float":
                    result[output_key] = float(value)
                elif type_value == "double":
                    result[output_key] = float(value)
                else:
                    result[output_key] = value  # 默认为字符串
        
        return result
    
    except ET.ParseError as e:
        print(f"XML解析错误: {e}")
        return result
    except Exception as e:
        print(f"处理过程中发生错误: {e}")
        return result

def get_yaml_value(file_path: str, key_path: str) -> any:
    """
    读取YAML文件中指定路径的值
    
    Args:
        file_path: YAML文件的完整路径
        key_path: 键的路径，使用冒号分隔（例如："ysc_base_node:ros__parameters:user_RL_gait"）
    
    Returns:
        指定键的值，如果不存在则返回None
    """
    try:
        # 读取YAML文件
        with open(file_path, 'r', encoding='utf-8') as f:
            data = yaml.safe_load(f)
        
        # 解析键路径
        keys = key_path.split(':')
        current = data
        
        # 逐级查找键
        for key in keys:
            if isinstance(current, dict) and key in current:
                current = current[key]
            else:
                return None  # 键不存在
        
        return current
    
    except FileNotFoundError:
        print(f"错误：文件不存在 - {file_path}")
        logger.error(f"错误：文件不存在，- {file_path} use_RL_gait 获取失败")
        return None
    except yaml.YAMLError as e:
        print(f"YAML解析错误：{e}")
        logger.error(f"YAML解析错误: {e}，use_RL_gait 获取失败")
        return None
    except Exception as e:
        print(f"读取文件时发生错误：{e}")
        logger.error(f"读取文件时发生错误: {e}，use_RL_gait 获取失败")
        return None

def get_env_value(file_path: str, key: str) -> any:
    """
    读取环境变量文件中指定键的值
    
    Args:
        file_path: 环境变量文件的完整路径
        key: 要查找的键名
    
    Returns:
        指定键的值，如果不存在则返回None
    """
    try:
        # 读取环境变量文件
        with open(file_path, 'r', encoding='utf-8') as f:
            for line in f:
                line = line.strip()
                # 跳过注释行和空行
                if line.startswith('#') or not line:
                    continue
                # 查找键值对
                if '=' in line:
                    k, v = line.split('=', 1)
                    if k.strip() == key:
                        return v.strip()
        return None
    
    except FileNotFoundError:
        print(f"错误：文件不存在 - {file_path}")
        logger.error(f"错误：文件不存在，- {file_path} {key} 获取失败")
        return None
    except Exception as e:
        print(f"读取文件时发生错误：{e}")
        logger.error(f"读取文件时发生错误: {e}，{key} 获取失败")
        return None

#能力模块
def get_ability_info(project_full_name):
    logger.info("get_ability_info")
    project_folder_path = os.path.join(project_dir, project_full_name)
    #任务决策模块
    #1.任务点限定朝向：限定/不限定
    mission_first_init_tree_xml = os.path.join(project_folder_path, 'config', 'rms_client', 'trees', 'mission', 'first_init_tree.xml')
    # 读取XML文件内容
    try:
        with open(mission_first_init_tree_xml, 'r', encoding='utf-8') as file:
            xml_content = file.read()
        task_point_ignore_orientation = extract_task_point_ignore_orientation(xml_content)
        
        #2.到达充点电是否上桩
        target_fields_1 = {
            "goto_dock"
        }
        mission_first_init_tree_result = extract_decision_first_init_tree(xml_content, target_fields_1)
    except FileNotFoundError:
        print(f"错误：文件不存在 - {mission_first_init_tree_xml}")
        logger.error("错误：文件不存在，任务点限定朝向+到达充点电是否上桩获取失败")
        task_point_ignore_orientation = None
        mission_first_init_tree_result = None
        raise Exception(f"文件不存在 - {mission_first_init_tree_xml}")
    except Exception as e:
        logger.error(f"解析XML文件失败: {str(e)}")
        task_point_ignore_orientation = None
        mission_first_init_tree_result = None
        raise Exception(f"解析XML文件失败 - {mission_first_init_tree_xml}")
    #3.任务低电回充，4.关节温度保护
    target_fields_2 = {
        "joint_temperature_protect",
        "low_temp_threshold",
        "high_temp_threshold",
        "low_battery_charge",
        "low_battery_threshold",
        "high_battery_threshold"
    }
    decision_first_init_tree_xml = os.path.join(project_folder_path, 'config', 'rms_client', 'trees', 'decision', 'first_init_tree.xml')
    # 读取XML文件内容
    try:
        with open(decision_first_init_tree_xml, 'r', encoding='utf-8') as file:
            xml_content = file.read()
        decision_first_init_tree_result = extract_decision_first_init_tree(xml_content, target_fields_2)
    except FileNotFoundError:
        print(f"错误：文件不存在 - {decision_first_init_tree_xml}")
        logger.error("错误：文件不存在，导航模块获取失败")
        decision_first_init_tree_result = None
        raise Exception(f"文件不存在 - {decision_first_init_tree_xml}")
    except Exception as e:
        logger.error(f"解析XML文件失败: {str(e)}")
        decision_first_init_tree_result = None
        raise Exception(f"解析XML文件失败 - {decision_first_init_tree_xml}")

    #导航模块
    #1.强化学习步态
    config_env_path = os.path.join(project_folder_path, 'config', 'nav', 'config.env')
    use_rl_gait_str = get_env_value(config_env_path, 'USE_RL_GAIT')
    # 将字符串转换为布尔值
    use_rl_gait = use_rl_gait_str.lower() == 'true' if use_rl_gait_str else None

    ability_info = {
        'project_full_name': project_full_name,
        'task_point_ignore_orientation': task_point_ignore_orientation,
        'goto_dock': mission_first_init_tree_result['goto_dock'],
        'low_battery_charge': decision_first_init_tree_result['low_battery_charge'],
        'low_battery_threshold': decision_first_init_tree_result['low_battery_threshold'],
        'high_battery_threshold': decision_first_init_tree_result['high_battery_threshold'],
        'joint_temperature_protect': decision_first_init_tree_result['joint_temperature_protect'],
        'low_temp_threshold': decision_first_init_tree_result['low_temp_threshold'],
        'high_temp_threshold': decision_first_init_tree_result['high_temp_threshold'],
        'use_RL_gait': use_rl_gait
    }
    return ability_info

def update_ability_info(param_dict: Dict[str, Any]) -> Dict[str, Any]:
    """
    更新项目的能力信息配置文件并保持原有字段顺序
    
    Args:
        param_dict: 包含更新参数的字典，必须包含project_full_name和需要更新的字段
    
    Returns:
        包含更新结果的字典
    
    Raises:
        ValueError: 缺少必要参数或更新过程中发生错误
    """
    project_full_name = param_dict.get('project_full_name')
    if not project_full_name:
        raise ValueError("缺少必要参数: project_full_name")
    
    # 构建文件路径
    project_folder_path = os.path.join(project_dir, project_full_name)
    result = {
        'success': False,
        'message': '',
        'updated_fields': {}
    }
    
    try:
        # 1. 更新任务点朝向忽略参数
        if 'task_point_ignore_orientation' in param_dict:
            if param_dict['task_point_ignore_orientation'] is not None:
                logger.info(f"更新任务点朝向忽忽略参数: {param_dict['task_point_ignore_orientation']}")
                success = update_task_point_ignore_orientation(
                    project_folder_path,
                    param_dict['task_point_ignore_orientation']
                )
            else:
                success = True
            if success:
                result['updated_fields']['task_point_ignore_orientation'] = param_dict['task_point_ignore_orientation']
        
        # 2. 更新决策参数
        decision_fields = [
            'low_battery_charge', 'low_battery_threshold',
            'high_battery_threshold', 'joint_temperature_protect',
            'low_temp_threshold', 'high_temp_threshold'
        ]
        mission_fields = ['goto_dock']
        decision_updated = False
        for field in decision_fields:
            if field in param_dict and param_dict[field] is not None:
                decision_updated = True
                break
        mission_updated = False
        for field in mission_fields:
            if field in param_dict and param_dict[field] is not None:
                mission_updated = True
                break
        
        if decision_updated:
            logger.info('Updating decision parameters...')
            decision_xml_path = os.path.join(project_folder_path, 'config', 'rms_client', 'trees', 'decision', 'first_init_tree.xml')
            update_decision_parameters(param_dict, decision_xml_path)
            for field in decision_fields:
                if field in param_dict:
                    result['updated_fields'][field] = param_dict[field]
        if mission_updated:
            logger.info('Updating mission parameters...')
            mission_xml_path = os.path.join(project_folder_path, 'config', 'rms_client', 'trees', 'mission', 'first_init_tree.xml')
            update_decision_parameters(param_dict, mission_xml_path)
            for field in mission_fields:
                if field in param_dict:
                    result['updated_fields'][field] = param_dict[field]
        # 3. 更新强化学习步态参数
        if 'use_rl_gait' in param_dict:
            if param_dict['use_rl_gait'] is not None:
                logger.info("更新强化学习步态参数")
                config_env_path = os.path.join(project_folder_path, 'config', 'nav', 'config.env')
                # 将布尔值转换为字符串
                use_rl_gait_value = str(param_dict['use_rl_gait']).lower()
                success = update_env_parameter(config_env_path, 'USE_RL_GAIT', use_rl_gait_value)
            else:
                success = True
            if success:
                result['updated_fields']['use_RL_gait'] = param_dict['use_rl_gait']
        
        if len(result['updated_fields']) == len(param_dict)-1:
            result['update_status'] = 'success'
            result['success'] = True
            result['message'] = f"成功更新 {len(result['updated_fields'])} 个字段"
            return result
        else:
            result['update_status'] = 'failure'
            result['message'] = f"{len(param_dict)-1 - len(result['updated_fields'])}个字段更新失败"
            return result
    
    except Exception as e:
        result['message'] = f"更新失败: {str(e)}"
        return result

def update_task_point_ignore_orientation(project_folder_path: str, value: Any) -> bool:
    value = value.lower()
    """更新任务点朝向忽略参数"""
    xml_path = os.path.join(project_folder_path, 'config', 'rms_client', 'trees', 'mission', 'first_init_tree.xml')
    if not os.path.exists(xml_path):
        print(f"错误: 文件不存在 - {xml_path}")
        return False
    
    try:
        tree = ET.parse(xml_path)
        root = tree.getroot()
        
        # 查找SetTypeBlackboard节点
        node = root.find(".//SetTypeBlackboard[@output_key='task_point_ignore_orientation']")
        if node is None:
            # 如果找不到SetTypeBlackboard，查找SetBlackboard
            node = root.find(".//SetBlackboard[@output_key='task_point_ignore_orientation']")
            if node is None:
                print("错误: 未找到task_point_ignore_orientation节点")
                return False
        
        # 设置值和类型（默认为string）
        node.set("value", str(value))
        if 'SetTypeBlackboard' in node.tag:
            node.set("type", "string" if isinstance(value, str) else "bool")
        
        # 保存修改
        tree.write(xml_path, encoding='utf-8', xml_declaration=True)
        return True
    
    except Exception as e:
        print(f"更新任务点朝向忽略参数失败: {str(e)}")
        return False

def update_decision_parameters(param_dict: Dict[str, Any], xml_path) -> bool:
    """更新决策参数"""
    if not os.path.exists(xml_path):
        print(f"错误: 文件不存在 - {xml_path}")
        return False
    
    try:
        tree = ET.parse(xml_path)
        root = tree.getroot()
        updated = False
        
        # 定义字段类型映射
        field_types = {
            'goto_dock': 'bool',
            'low_battery_charge': 'bool',
            'joint_temperature_protect': 'bool',
            'low_battery_threshold': 'double',
            'high_battery_threshold': 'double',
            'low_temp_threshold': 'float',
            'high_temp_threshold': 'float'
        }
        
        # 遍历所有SetTypeBlackboard节点
        for node in root.findall(".//SetTypeBlackboard"):
            output_key = node.get("output_key")
            if output_key in param_dict and param_dict[output_key] is not None:
                value = str(param_dict[output_key]).lower()
                node.set("value", value)
                node.set("type", field_types.get(output_key, "string"))
                updated = True
        
        # 保存修改
        if updated:
            tree.write(xml_path, encoding='utf-8', xml_declaration=True)
        return updated
    
    except Exception as e:
        print(f"更新决策参数失败: {str(e)}")
        return False


def update_ros2_parameter(project_folder_path: str, key_path: str, value: Any) -> bool:
    """更新ROS2参数并保持YAML顺序"""
    yaml_path = os.path.join(project_folder_path, 'config', 'nav', 'ros2_params.yaml')
    logger.info(f"更新ROS2参数: {key_path} = {value}")
    logger.info(f"YAML文件路径: {yaml_path}")
    
    if not os.path.exists(yaml_path):
        print(f"错误: 文件不存在 - {yaml_path}")
        logger.error(f"文件不存在 - {yaml_path}")
        return False
    
    try:
        # 配置YAML解析器
        yaml = YAML()
        yaml.preserve_quotes = True
        yaml.indent(mapping=2, sequence=4, offset=2)
        yaml.width = 4096  # 设置宽以避免长行被折叠
        
        # 读取YAML内容
        with open(yaml_path, 'r', encoding='utf-8') as f:
            data = yaml.load(f)
        
        if data is None:
            data = CommentedMap()
            logger.info("YAML文件为空，创建新的CommentedMap")
        
        # 解析键路径
        keys = key_path.split(':')
        logger.info(f"解析键路径: {keys}")
        
        # 逐级查找或创建键
        current = data
        for i, key in enumerate(keys):
            if i == len(keys) - 1:
                # 目标键，设置值
                current[key] = value
                logger.info(f"设置值: {key} = {value}")
                # 保留原有的注释和行结构
                if isinstance(current, CommentedMap) and key not in current.ca.items:
                    # 如果是新添加的键，尝试保持一致的格式
                    logger.info(f"添加新键: {key}")
            else:
                # 中间键，确保存在并移动到下一层
                if key not in current:
                    # 创建新的CommentedMap保持顺序
                    current[key] = CommentedMap()
                    logger.info(f"创建中间键: {key}")
                elif not isinstance(current[key], CommentedMap):
                    # 如果不是映射类型，替换为CommentedMap
                    logger.warning(f"替换非映射类型 '{key}' 为 CommentedMap")
                current = current[key]
                logger.info(f"导航到键: {key}")
        
        # 写入更新后的YAML
        with open(yaml_path, 'w', encoding='utf-8') as f:
            yaml.dump(data, f)
        
        logger.info("YAML文件更新成功")
        return True
    
    except Exception as e:
        print(f"更新ROS2参数失败: {str(e)}")
        logger.error(f"更新ROS2参数失败: {str(e)}")
        return False

# endregion 能力模块配置

# region 服务器连接状态
def check_nc(ip, port):
    """
    使用系统nc命令检查指定IP和端口的连通性
    
    参数:
        ip (str): 目标IP地址
        port (int): 目标端口号
        
    返回:
        tuple: (连通状态, 输出信息)
               连通状态为True或False
               输出信息为nc命令的标准输出或错误信息
    """
    try:
        # 执行nc命令，-z表示零I/O模式(仅测试连通性)，-w2设置超时时间为2秒
        result = subprocess.run(
            ['nc', '-zv', '-w2', ip, str(port)],
            capture_output=True,
            text=True
        )
        
        # 判断命令执行状态
        if result.returncode == 0:
            return True, result.stdout
        else:
            return False, result.stderr
            
    except Exception as e:
        # 处理命令执行异常
        return False, f"执行nc命令时出错: {str(e)}"

def get_comm_info():
    with open(env_path, 'r') as file:
        for line in file:
            line = line.strip()
            # print("line---", line)
            if line.startswith('SERVER_IP='):
                server_ip = line.split('=', 1)[1].strip()

    is_connected, output = check_nc(server_ip, ftp_port)
    logger.info(output)
    return {
        "serverIp": server_ip,
        "isConnected": is_connected
    }

def get_comm_state(ros_flask_ip, ros_flask_port, robot_id):
    try:
        # 构建目标服务URL
        # 假设两个容器在同一网络中，可通过服务名或IP访问
        target_url = f"http://{ros_flask_ip}:{ros_flask_port}/api/v1/robotconsole/ros/info/comm-state"
        # 添加 robot_id 作为查询参数
        params = {'robot_id': robot_id}
        
        # 发送请求到另一个Flask服务
        response = requests.get(target_url, params=params, timeout=10)
        
        # 检查响应状态
        response.raise_for_status()  # 如果请求失败，抛出异常
        logger.info(f"response.json(): {response.json()}")
        # 返回获取的数据
        response_camel = dict_to_camel(response.json())
        return jsonify(response_camel), 200
        
    except requests.exceptions.RequestException as e:
        logger.error(f"请求ROS服务失败: {str(e)}")
        return jsonify({"error": "无运行中的项目，请创建或启动项目"}), 503
    except Exception as e:
        logger.exception("获取服务器连接状态时发生错误")
        return jsonify({"error": "获取服务器连接状态失败"}), 500 
    
# endregion 服务器连接状态

# region 项目信息
def get_project_info():
    current_project = ''
    current_server = ''
    server_ip = ''
    rms_version = ''
    env_path = os.path.join(script_dir, ".env")
    with open(env_path, 'r') as file:
            for line in file:
                line = line.strip()
                # print("line---", line)
                if line.startswith('CURRENT_PROJECT='):
                    # print("project line")
                    current_project = line.split('=', 1)[1].strip()
                    # print(current_project)
                elif line.startswith('CURRENT_SERVER='):
                    # print("server line")
                    current_server = line.split('=', 1)[1].strip()
                elif line.startswith('SERVER_IP='):
                    # print("server line")
                    server_ip = line.split('=', 1)[1].strip()
                # else:
                #     print("startswith undetected", line)
    if current_project != "":
        rms_version = current_project.split('_')[1]
        current_project = current_project.split('_')[0]
        full_project_name = current_project + "_" + rms_version
    else:
        full_project_name = ""
    return {
        "fullProjectName": full_project_name,
        "projectName": current_project,
        "rmsVersion": rms_version,
        "serverType": current_server,
        "serverIp": server_ip
    }

def get_sysb_version():
    mgr = SysbManager()
    new_sysb_version = mgr.get_sysb_version()
    return new_sysb_version

# 查看项目名是否重名 根据project下的目录名称 true 重名  false 不重名
def project_exist(zip_dir_name) -> bool:
    is_exist = False
    for dir_name in os.listdir(project_dir):
        if os.path.isdir(os.path.join(project_dir, dir_name)) and dir_name == zip_dir_name:
            is_exist =  True
            continue
         
    return is_exist

# endregion 项目信息

# region 告警信息
def get_alarms(ros_flask_ip, ros_flask_port):
    try:
        target_url = f"http://{ros_flask_ip}:{ros_flask_port}/api/v1/robotconsole/ros/alarms"
        
        # 发送请求到另一个Flask服务
        response = requests.get(target_url, timeout=10)
        
        # 检查响应状态
        response.raise_for_status()  # 如果请求失败，抛出异常
        # logger.info(f"response.json(): {response.json()}")
        # 返回获取的数据
        response_camel = dict_to_camel(response.json())
        return jsonify(response_camel), 200
        
    except requests.exceptions.RequestException as e:
        logger.error(f"请求ROS服务失败: {str(e)}")
        return jsonify({"error": "无运行中的项目，请创建或启动项目"}), 200
    except Exception as e:
        logger.exception("获取告警信息时发生错误")
        return jsonify({"error": "获取告警信息失败"}), 200

def respond_alarm(param_dict, ros_flask_ip, ros_flask_port):
    logger.info("respond_alarm")
    try:
        target_url = f"http://{ros_flask_ip}:{ros_flask_port}/api/v1/robotconsole/ros/alarms/respond"
        response = requests.post(target_url, json=param_dict, timeout=10)
        
        # 优先解析响应内容
        try:
            response_data = response.json()
            message = response_data.get("message", "接口未返回具体信息")
        except json.JSONDecodeError:
            message = response.text or "响应内容无法解析"  #  fallback to raw text
        
        # 根据响应状态码抛出异常（保留原始状态码信息）
        response.raise_for_status()
        
        logger.info(f"接口响应: {response_data}")
        response_camel = dict_to_camel(response.json())
        return jsonify(response_camel), response.status_code  # 返回原始状态码和数据
        
    except requests.exceptions.HTTPError as e:
        # 处理HTTP错误（如4xx/5xx状态码）
        try:
            error_data = e.response.json()
            message = error_data.get("message", f"HTTP错误: {e.response.status_code}")
        except:
            message = f"HTTP请求失败: {e.response.status_code}"
        logger.error(f"HTTP错误: {message}")
        return jsonify({"error": message}), e.response.status_code if e.response else 503
        
    except requests.exceptions.RequestException as e:
        # 处理连接超时、网络错误等
        message = f"请求ROS服务失败: {str(e)}"
        logger.error(message)
        return jsonify({"error": message}), 503  # 服务不可用状态码
        
    except Exception as e:
        # 兜底异常处理
        logger.exception("处理告警响应时发生未知错误")
        return jsonify({"error": "处理告警响应失败"}), 500
    

def clear_all_alarms(ros_flask_ip, ros_flask_port):
    logger.info("clear_all_alarms")
    try:
        target_url = f"http://{ros_flask_ip}:{ros_flask_port}/api/v1/robotconsole/ros/alarms/clear-all"
        
        # 发送请求到另一个Flask服务
        response = requests.post(target_url, timeout=10)
        
        # 检查响应状态
        response.raise_for_status()  # 如果请求失败，抛出异常
        logger.info(f"response.json(): {response.json()}")
        # 返回获取的数据
        response_camel = dict_to_camel(response.json())
        return jsonify(response_camel), 200
        
    except requests.exceptions.RequestException as e:
        logger.error(f"请求ROS服务失败: {str(e)}")
        return jsonify({"error": "无运行中的项目，请创建或启动项目"}), 503
    except Exception as e:
        logger.exception("清空告警响应时发生错误")
        return jsonify({"error": "清空告警响应失败"}), 500


def read_one_alarm(param_dict, ros_flask_ip, ros_flask_port):
    logger.info("read_one_alarm")
    try:
        target_url = f"http://{ros_flask_ip}:{ros_flask_port}/api/v1/robotconsole/ros/alarms/read"
        
        # 发送请求到另一个Flask服务
        response = requests.post(target_url, json=param_dict,  timeout=10)
        
        # 优先解析响应内容
        try:
            response_data = response.json()
            message = response_data.get("message", "接口未返回具体信息")
        except json.JSONDecodeError:
            message = response.text or "响应内容无法解析"  #  fallback to raw text
        
        # 根据响应状态码抛出异常（保留原始状态码信息）
        response.raise_for_status()
        
        logger.info(f"接口响应: {response_data}")
        response_camel = dict_to_camel(response.json())
        return jsonify(response_camel), response.status_code  # 返回原始状态码和数据
        
   
    except requests.exceptions.HTTPError as e:
        # 处理HTTP错误（如4xx/5xx状态码）
        try:
            error_data = e.response.json()
            message = error_data.get("message", f"HTTP错误: {e.response.status_code}")
        except:
            message = f"HTTP请求失败: {e.response.status_code}"
        logger.error(f"HTTP错误: {message}")
        return jsonify({"error": message}), e.response.status_code if e.response else 503
        
    except requests.exceptions.RequestException as e:
        # 处理连接超时、网络错误等
        message = f"请求ROS服务失败: {str(e)}"
        logger.error(message)
        return jsonify({"error": message}), 503  # 服务不可用状态码
        
    except Exception as e:
        # 兜底异常处理
        logger.exception("处理告警响应时发生未知错误")
        return jsonify({"error": "处理告警响应失败"}), 500


def read_all_alarm(ros_flask_ip, ros_flask_port):
    logger.info("read_all_alarms")
    try:
        target_url = f"http://{ros_flask_ip}:{ros_flask_port}/api/v1/robotconsole/ros/alarms/read-all"
        
        # 发送请求到另一个Flask服务
        response = requests.post(target_url, timeout=10)
        
        # 检查响应状态
        response.raise_for_status()  # 如果请求失败，抛出异常
        logger.info(f"response.json(): {response.json()}")
        # 返回获取的数据
        response_camel = dict_to_camel(response.json())
        return jsonify(response_camel), 200
        
    except requests.exceptions.RequestException as e:
        logger.error(f"请求ROS服务失败: {str(e)}")
        return jsonify({"error": "无运行中的项目，请创建或启动项目"}), 503
    except Exception as e:
        logger.exception("已读多条告警响应时发生错误")
        return jsonify({"error": "清空告警响应失败"}), 500

# endregion 告警信息

# region 系统更新

# region 检查系统更新
def get_system_version(version_yaml_path):
    """
    解析新格式version.yaml文件，获取系统版本信息
    
    支持的文件格式：
    version: 1.0.3.20251116  # 版本号（支持任意格式字符串/数字）
    date: 2025-11-16-16:00:00  # 发布日期时间
    
    Args:
        version_yaml_path: version.yaml文件路径
    
    Returns:
        dict: 版本信息字典，格式如下：
            {
                "type": "stable",  # 固定为stable（新格式默认）
                "version": "1.0.3.20251116",  # 版本号（统一转为字符串）
                "date": "2025-11-16-16:00:00",  # 发布日期（统一转为字符串）
            }
    
    Raises:
        FileNotFoundError: 文件不存在
        yaml.YAMLError: YAML文件解析失败
        ValueError: 缺少必要字段（version或date）
    """
    # 检查文件是否存在
    if not os.path.exists(version_yaml_path):
        raise FileNotFoundError(f"YAML文件不存在: {version_yaml_path}")
    
    # 读取并解析YAML文件
    try:
        with open(version_yaml_path, 'r', encoding='utf-8') as file:
            yaml_content = yaml.safe_load(file)
    except yaml.YAMLError as e:
        raise yaml.YAMLError(f"解析YAML文件失败: {str(e)}") from e
    
    # 校验必要字段（version和date必须存在）
    required_fields = ['version', 'date']
    missing_fields = [field for field in required_fields if field not in yaml_content]
    if missing_fields:
        raise ValueError(f"YAML文件缺少必要字段: {', '.join(missing_fields)}")
    
    # 提取字段并统一转为字符串（避免数字格式差异）
    return {
        "type": "stable",  # 新格式固定为stable（可根据业务需求调整）
        "version": str(yaml_content['version']),
        "date": str(yaml_content['date'])
    }

def check_update_network_requriements():
    """
    检查机器人网络连接状态，包括互联网连接和版本仓库访问
    
    返回:
        Flask响应对象 - 成功时返回空JSON，失败时返回错误信息
    """
    # 1. 检查互联网连接 (ping www.baidu.com)
    logger.info("Checking internet connection...")
    try:
        ping_cmd = ['ping', '-c', '2', 'www.baidu.com']
        
        # 执行ping命令，超时时间设为30秒
        result = subprocess.run(ping_cmd, stdout=subprocess.PIPE, 
                               stderr=subprocess.PIPE, timeout=30)
        logger.info(f"ping result: {result}")
        # 检查返回码
        if result.returncode != 0:
            return Response(json.dumps({
                'code': 4001, 
                'message': '机器人未连接互联网，请先联网'
            }), status=400, mimetype='application/json')
    
    except subprocess.TimeoutExpired:
        return Response(json.dumps({
            'code': 4001, 
            'message': '机器人未连接互联网，请先联网'
        }), status=400, mimetype='application/json')
    
    except Exception as e:
        return Response(json.dumps({
            'code': 4001, 
            'message': f'检查互联网连接时发生错误: {str(e)}'
        }), status=500, mimetype='application/json')
    
    # 2. 检查版本仓库访问 (nc -zv im.daystar.cloud 1088)
    logger.info("Checking version repository access...")
    try:
        # 使用nc命令检查端口连通性，增强超时处理
        nc_cmd = ['nc', '-zv', 'im.daystar.cloud', '1088']
        
        # 使用更健壮的超时处理，增加对超时异常的捕获
        process = subprocess.Popen(nc_cmd, stdout=subprocess.PIPE, 
                                 stderr=subprocess.PIPE, text=True)
        
        # 设定超时时间并等待进程完成
        try:
            stdout, stderr = process.communicate(timeout=30)
            result_code = process.returncode
        except subprocess.TimeoutExpired:
            logger.warning(f"执行命令超时: {nc_cmd}")
            process.kill()  # 杀死超时进程
            stdout, stderr = process.communicate()
            result_code = process.returncode
        
        logger.info(f"nc command output: {stdout}")
        logger.info(f"nc command error: {stderr}")
        logger.info(f"nc command return code: {result_code}")
        
        # 检查返回码
        if result_code != 0:
            logger.info(f"nc command failed with return code: {result_code}")
            return Response(json.dumps({
                'code': 4002, 
                'message': '版本仓库服务未响应，请检查仓库状态'
            }), status=400, mimetype='application/json')
    
    except Exception as e:
        return Response(json.dumps({
            'code': 4002, 
            'message': f'检查版本仓库访问时发生错误: {str(e)}'
        }), status=500, mimetype='application/json')
    
    # 所有检查通过
    logger.info('网络连通正常')

# 下载并解压部署包（function 1
# 确保只解压到指定目录，避免目录穿越
def is_within_directory(directory, target):
    
    abs_directory = os.path.abspath(directory)
    abs_target = os.path.abspath(target)

    prefix = os.path.commonprefix([abs_directory, abs_target])
    
    return prefix == abs_directory

# 下载并解压部署包（function 2
def safe_extract(tar, path=".", members=None, *, numeric_owner=False, overwrite=True):
    """
    安全地解压TAR文件
    
    参数:
    tar -- tarfile对象
    path -- 解压目标路径
    members -- 要解压的成员列表
    numeric_owner -- 是否使用数字用户/组ID
    overwrite -- 是否覆盖已存在的文件
    """
    # 确保目标目录存在
    os.makedirs(path, exist_ok=True)
    
    # 获取所有成员
    if members is None:
        members = tar.getmembers()
    
    # 检查每个成员的安全性
    for member in members:
        member_path = os.path.join(path, member.name)
        
        # 防止目录穿越
        if not is_within_directory(path, member_path):
            raise Exception(f"不安全的解压路径: {member.name}")
        
        # 处理文件覆盖
        if os.path.exists(member_path) and not overwrite:
            print(f"跳过已存在的文件: {member.name}")
            continue
        
        # 解压单个成员
        tar.extract(member, path, numeric_owner=numeric_owner)
    
    print(f"成功解压 {len(members)} 个文件到 {path}")

# 下载并解压部署包（用于检查更新阶段）
def download_and_extract_for_check():
    """
    从指定URL下载RMS包（稳定版和开发版）并解压到临时目录用于版本比对
    
    解压目录结构：
        /opt/sandbox/check_update/sysb-deployment/      (稳定版)
        /opt/sandbox/check_update/sysb-deployment-dev/  (开发版)
    
    返回:
        dict: 包含两个版本的路径信息
            - check_update_dir: 检查更新目录根路径
            - stable_dir: 稳定版解压目录（可能为None如果下载失败）
            - dev_dir: 开发版解压目录（可能为None如果下载失败）
            - downloaded_packages: 成功下载的包名列表
    """
    # 配置下载地址和文件名
    base_url = "http://im.daystar.cloud:1088/daystarBot/RELEASE/SYSB/"
    package_files = [
        "sysb-deployment.tar.gz",       # 稳定版
        "sysb-deployment-dev.tar.gz"    # 开发版
    ]
    
    # 创建检查更新的临时目录
    os.makedirs(CHECK_UPDATE_DIR, exist_ok=True)
    
    # 记录成功下载的包
    downloaded_packages = []
    
    try:
        # 下载并解压每个包
        for package_name in package_files:
            url = base_url + package_name
            local_path = os.path.join(CHECK_UPDATE_DIR, package_name)
            
            logger.info(f"正在下载: {url} 到 {local_path}")
            
            try:
                # 发送GET请求，设置流式下载
                with closing(requests.get(url, stream=True, timeout=600)) as response:
                    if response.status_code == 404:
                        logger.warning(f"文件未找到（可能该版本不存在）: {url}")
                        continue
                    elif response.status_code != 200:
                        logger.error(f"下载失败，状态码: {response.status_code}，URL: {url}")
                        continue
                    
                    # 写入文件
                    with open(local_path, 'wb') as f:
                        for chunk in response.iter_content(chunk_size=8192): 
                            if chunk: 
                                f.write(chunk)
                
                # 验证文件存在
                if not os.path.exists(local_path):
                    logger.error(f"下载的文件不存在: {local_path}")
                    continue
                
                # 解压文件到检查更新目录
                logger.info(f"正在解压: {local_path} 到 {CHECK_UPDATE_DIR}")
                with tarfile.open(local_path, 'r:gz') as tar:
                    safe_extract(tar, CHECK_UPDATE_DIR)
                
                downloaded_packages.append(package_name)
                logger.info(f"成功下载并解压: {package_name}")
                
            except requests.exceptions.RequestException as e:
                logger.error(f"下载 {package_name} 时网络错误: {e}")
                continue
            except tarfile.TarError as e:
                logger.error(f"解压 {package_name} 失败: {e}")
                continue
        
        # 检查是否至少下载成功一个包
        if not downloaded_packages:
            raise Exception("所有部署包下载失败，请检查网络连接或服务器状态")
        
        # 构建返回信息
        stable_dir = os.path.join(CHECK_UPDATE_DIR, "sysb-deployment")
        dev_dir = os.path.join(CHECK_UPDATE_DIR, "sysb-deployment-dev")
        
        extract_info = {
            # 新结构字段
            'check_update_dir': CHECK_UPDATE_DIR,
            'stable_dir': stable_dir if os.path.exists(stable_dir) else None,
            'dev_dir': dev_dir if os.path.exists(dev_dir) else None,
            'downloaded_packages': downloaded_packages,
            # 兼容旧代码的字段（供 system_update 等老函数使用）
            # 老代码期望 extract_dir 为根目录，然后拼接 sysb-deployment 或 sysb-deployment-dev
            'temp_dir': CHECK_UPDATE_DIR,
            'extract_dir': CHECK_UPDATE_DIR,
            'package_files': downloaded_packages
        }
        logger.info(f"检查更新下载完成: {extract_info}")
        return extract_info
    
    except Exception as e:
        # 发生错误时记录日志但不清理目录（可能部分下载成功）
        logger.error(f'RMS包下载和解压失败: {e}')
        raise Exception(f'RMS包下载和解压失败: {e}')


def apply_selected_version(version_type: str) -> bool:
    """
    将用户选择的版本从检查更新目录复制到最终部署目录
    
    Args:
        version_type: 版本类型，"stable" 或 "dev"
    
    Returns:
        bool: 成功返回True
    
    Raises:
        ValueError: 无效的版本类型
        FileNotFoundError: 源目录不存在
        Exception: 复制失败
    """
    # 确定源目录
    if version_type == "stable":
        source_dir = os.path.join(CHECK_UPDATE_DIR, "sysb-deployment")
    elif version_type == "dev":
        source_dir = os.path.join(CHECK_UPDATE_DIR, "sysb-deployment-dev")
    else:
        raise ValueError(f"无效的版本类型: {version_type}，仅支持 'stable' 或 'dev'")
    
    # 检查源目录是否存在
    if not os.path.exists(source_dir):
        raise FileNotFoundError(f"版本目录不存在: {source_dir}，请先执行检查更新")
    
    logger.info(f"开始应用 {version_type} 版本: {source_dir} → {FINAL_DEPLOYMENT_DIR}")
    
    try:
        # 如果目标目录已存在，先删除（使用 onerror 处理只读文件）
        if os.path.exists(FINAL_DEPLOYMENT_DIR):
            logger.info(f"删除旧的部署目录: {FINAL_DEPLOYMENT_DIR}")
            
            def remove_readonly(func, path, excinfo):
                """处理只读文件的删除"""
                os.chmod(path, stat.S_IWRITE | stat.S_IREAD)
                func(path)
            
            shutil.rmtree(FINAL_DEPLOYMENT_DIR, onerror=remove_readonly)
            
            # 确认删除成功
            if os.path.exists(FINAL_DEPLOYMENT_DIR):
                raise Exception(f"删除旧部署目录失败，目录仍存在: {FINAL_DEPLOYMENT_DIR}")
            logger.info(f"旧部署目录删除成功")
        
        # 复制选中的版本到最终部署目录
        logger.info(f"复制 {version_type} 版本到部署目录")
        shutil.copytree(source_dir, FINAL_DEPLOYMENT_DIR)
        
        # 验证复制成功
        if not os.path.exists(FINAL_DEPLOYMENT_DIR):
            raise Exception(f"复制失败，目标目录不存在: {FINAL_DEPLOYMENT_DIR}")
        
        logger.info(f"成功应用 {version_type} 版本到 {FINAL_DEPLOYMENT_DIR}")
        return True
    
    except Exception as e:
        error_msg = f"应用 {version_type} 版本失败: {e}"
        logger.error(error_msg)
        raise Exception(error_msg)


# 兼容旧代码：保留原函数名，内部调用新函数
def rms_package_download_and_unzip():
    """
    兼容旧代码的函数，内部调用 download_and_extract_for_check()
    
    返回:
        dict: 兼容旧格式的返回值
    """
    extract_info = download_and_extract_for_check()
    
    # 检查是否有可用的部署包
    if not extract_info.get('stable_dir') and not extract_info.get('dev_dir'):
        raise Exception("没有可用的部署包")
    
    # 返回兼容旧格式的数据
    # 注意：extract_dir 指向根目录，老代码会拼接 sysb-deployment 或 sysb-deployment-dev
    return {
        'temp_dir': extract_info['check_update_dir'],
        'extract_dir': extract_info['check_update_dir'],
        'package_files': extract_info['downloaded_packages'],
        # 新增字段，供 check_system_update 使用
        'stable_dir': extract_info.get('stable_dir'),
        'dev_dir': extract_info.get('dev_dir')
    }

# 比较大版本号是否有更新（function 1
def compare_version_dates(date_str1, date_str2):
    """
    比较两个日期字符串（支持两种格式）：
    1. 8位数字日期（如"20250605"，来自version字段的日期部分）
    2. 带时间的日期（如"2025-11-16-16:00:00"，来自date字段）
    
    参数:
    date_str1 -- 第一个日期字符串
    date_str2 -- 第二个日期字符串
    
    返回:
    int -- 1 表示version1更新，-1 表示version2更新，0 表示日期相同
    """
    # 定义支持的日期格式（按优先级匹配）
    date_formats = [
        "%Y%m%d",                  # 8位数字（如20250605）
        "%Y-%m-%d-%H:%M:%S",       # 带时间（如2025-11-16-16:00:00）
        "%Y-%m-%d"                 # 仅日期（如2025-11-16，兼容扩展格式）
    ]
    
    def parse_date(date_str):
        """解析日期字符串，返回datetime对象，失败则抛出异常"""
        for fmt in date_formats:
            try:
                return datetime.strptime(date_str, fmt)
            except ValueError:
                continue
        raise ValueError(f"日期格式不支持：{date_str}（支持格式：8位数字/YYYY-MM-DD-HH:MM:SS/YYYY-MM-DD）")
    
    # 解析两个日期
    date1 = parse_date(date_str1)
    date2 = parse_date(date_str2)
    
    # 比较日期
    if date1 > date2:
        return 1
    elif date1 < date2:
        return -1
    else:
        return 0

# 比较大版本号是否有更新（function 2
def compare_semantic_version(version1, version2):
    """
    比较完整版本号（格式：X.Y.Z.日期戳，如1.0.3.20251116）
    对比规则：主版本→次版本→修订版→日期戳前缀（前3位按数字对比，最后1位按日期对比）
    
    参数:
    version1 -- 第一个版本字符串
    version2 -- 第二个版本字符串
    
    返回:
    int -- 1 表示version1更新，-1 表示version2更新，0 表示版本相同
    """
    # 分割版本部分（必须是4段：主.次.修订.日期戳）
    def split_version(v):
        parts = v.split('.')
        if len(parts) != 4:
            raise ValueError(f"版本格式不正确：{v}（需满足X.Y.Z.日期戳，如1.0.3.20251116）")
        # 前3段转为整数，最后1段保留为日期字符串
        try:
            main = int(parts[0])
            minor = int(parts[1])
            patch = int(parts[2])
            date_part = parts[3]
            return (main, minor, patch, date_part)
        except ValueError:
            raise ValueError(f"版本字段包含非数字：{v}（前3段需为整数，最后1段为8位日期）")
    
    # 分割两个版本
    v1_main, v1_minor, v1_patch, v1_date = split_version(version1)
    v2_main, v2_minor, v2_patch, v2_date = split_version(version2)
    
    # 对比主版本
    if v1_main > v2_main:
        return 1
    elif v1_main < v2_main:
        return -1
    
    # 主版本相同，对比次版本
    if v1_minor > v2_minor:
        return 1
    elif v1_minor < v2_minor:
        return -1
    
    # 次版本相同，对比修订版
    if v1_patch > v2_patch:
        return 1
    elif v1_patch < v2_patch:
        return -1
    
    # 修订版相同，对比日期戳（调用日期对比方法）
    return compare_version_dates(v1_date, v2_date)

# 比较大版本号是否有更新（function 3
def compare_version_detail(version_detail_1, version_detail_2):
    """
    对比两个版本详情对象（先比version，再比date）
    
    比较规则：
        1. 首先比较version字段（包括版本号中的日期戳部分）
        2. 如果version完全相同，再比较独立的date字段（带时间的完整日期）
    
    参数:
    version_detail_1 -- 第一个版本详情字典，格式：{"type": str, "version": str, "date": str}
    version_detail_2 -- 第二个版本详情字典，格式同上
    
    返回:
    int -- 1 表示version_detail_1更新，-1 表示version_detail_2更新，0 表示完全相同
    """
    # 提取版本号和日期
    version1 = version_detail_1.get('version')
    version2 = version_detail_2.get('version')
    date1 = version_detail_1.get('date')
    date2 = version_detail_2.get('date')
    
    # 校验version字段（必须存在）
    if not version1 or not version2:
        raise ValueError("版本详情缺少version字段")
    
    logger.info(f"比较版本: version1={version1}, version2={version2}")
    logger.info(f"比较日期: date1={date1}, date2={date2}")
    
    # 第一步：对比完整版本号（包括版本号中的日期戳部分，如 1.0.7.20260120）
    try:
        version_compare = compare_semantic_version(version1, version2)
        logger.info(f"版本号比较结果: {version_compare} (1=version1更新, -1=version2更新, 0=相同)")
        
        if version_compare != 0:
            # 版本号不同，直接返回结果
            return version_compare
    except Exception as e:
        # 如果版本号格式不支持（比如不是4段式），回退到字符串比较
        logger.warning(f"版本号格式可能不支持语义化比较: {e}，使用字符串比较")
        if version1 > version2:
            return 1
        elif version1 < version2:
            return -1
        # 字符串相同，继续比较date字段
    
    # 第二步：版本号完全相同，对比date字段（带时间的完整日期，如 2026-01-20-16:51:00）
    if not date1 and not date2:
        # 两个都没有date字段，视为完全相同
        logger.info("两个版本都没有date字段，视为完全相同")
        return 0
    elif date1 and not date2:
        # version1有date而version2没有，视为version1更新（有更详细的信息）
        logger.info("version1有date而version2没有，视为version1更新")
        return 1
    elif not date1 and date2:
        # version2有date而version1没有，视为version2更新
        logger.info("version2有date而version1没有，视为version2更新")
        return -1
    else:
        # 两个都有date字段，使用compare_version_dates进行比较
        try:
            date_compare = compare_version_dates(date1, date2)
            logger.info(f"日期比较结果: {date_compare} (1=date1更新, -1=date2更新, 0=相同)")
            return date_compare
        except Exception as e:
            # 日期格式不支持或解析失败，视为相同（保守策略）
            logger.warning(f"日期比较失败: {e}，视为相同")
            return 0

# 检查子模块的manifest.yaml
def compare_manifest_yaml(current_manifest_path, sandbox_manifest_path):
    """
    原生实现语义化版本比较（不依赖三方包）：current比sandbox老则返回True（有更新），否则返回False
    
    版本格式约束：固定为 vX.Y.Z（X、Y、Z为非负整数，如v1.0.1、v2.3.10）
    比较规则：
        1. 首先比较version字段（主版本号优先级最高，其次是次版本号，最后是修订版号）
        2. 如果version相同，再比较release_date字段（日期更新的视为更新）
    异常处理：文件不存在、解析失败、版本格式非法时，返回False（视为无更新）
    
    Args:
        current_manifest_path: rms_dir侧的manifest路径（当前版本）
        sandbox_manifest_path: sandbox侧的manifest路径（新候选版本）
    
    Returns:
        bool: current比sandbox老 → True（需要更新），否则False
    """
    # 1. 解析版本号为数字列表（如 "v1.0.1" → [1, 0, 1]）
    def parse_version(version_str):
        """将版本字符串（vX.Y.Z）解析为整数列表，解析失败返回None"""
        # 校验前缀是否为"v"，且长度符合基本格式
        if not isinstance(version_str, str) or not version_str.startswith('v') or len(version_str) < 5:
            return None
        
        try:
            # 去除前缀"v"，按"."分割为字符串列表
            version_parts = version_str.lstrip('v').split('.')
            # 必须是3个部分（X.Y.Z），且均为非负整数
            if len(version_parts) != 3:
                return None
            # 转换为整数列表（校验是否为有效数字）
            return [int(part) for part in version_parts]
        except (ValueError, TypeError):
            # 若有非数字部分，返回None
            return None
    
    # 2. 读取并加载manifest中的version和release_date字段
    def load_manifest_data(manifest_path):
        """
        读取manifest文件，返回包含version和release_date的字典
        返回格式: {'version': [1, 0, 1], 'release_date': '2026-01-20'} 或 None
        """
        # 检查文件是否存在
        if not os.path.exists(manifest_path):
            logger.warning(f"文件不存在 → {manifest_path}")
            return None
        
        try:
            # 读取YAML文件（支持UTF-8编码）
            with open(manifest_path, 'r', encoding='utf-8') as f:
                data = yaml.safe_load(f)
            
            # 提取version字段（必须存在且为字符串）
            version_str = data.get('version', None)
            if not isinstance(version_str, str):
                logger.warning(f"{manifest_path} 中version字段不存在或格式非法")
                return None
            
            # 解析版本号为数字列表
            version_list = parse_version(version_str)
            if version_list is None:
                logger.warning(f"{manifest_path} 中version字段格式错误: {version_str}")
                return None
            
            # 提取release_date字段（可选，如果不存在则为None）
            release_date = data.get('release_date', None)
            if release_date and not isinstance(release_date, str):
                logger.warning(f"{manifest_path} 中release_date字段格式非法，将忽略")
                release_date = None
            
            return {
                'version': version_list,
                'release_date': release_date
            }
        except yaml.YAMLError as e:
            logger.error(f"解析{manifest_path} YAML失败 → {str(e)}")
            return None
        except Exception as e:
            logger.error(f"处理{manifest_path}时异常 → {str(e)}")
            return None
    
    # 3. 加载两个文件的manifest数据
    current_data = load_manifest_data(current_manifest_path)
    sandbox_data = load_manifest_data(sandbox_manifest_path)
    
    if not current_data or not sandbox_data:
        logger.warning(f"无法加载manifest数据，视为无更新")
        return False  # 任一数据无效，视为无更新
    
    current_version = current_data['version']
    sandbox_version = sandbox_data['version']
    current_date = current_data.get('release_date')
    sandbox_date = sandbox_data.get('release_date')
    
    logger.info(f"Comparing versions: current={current_version}, sandbox={sandbox_version}")
    logger.info(f"Comparing dates: current={current_date}, sandbox={sandbox_date}")
    
    # 4. 首先比较version字段（按主版本→次版本→修订版逐位比较）
    for curr_part, sand_part in zip(current_version, sandbox_version):
        if curr_part < sand_part:
            # current某一位版本更小 → current更老，返回True（有更新）
            logger.info(f"版本号不同：current < sandbox，需要更新")
            return True
        elif curr_part > sand_part:
            # current某一位版本更大 → current更新，返回False（无更新）
            logger.info(f"版本号不同：current > sandbox，无需更新")
            return False
    
    # 5. 版本号完全相同，比较release_date字段
    logger.info(f"版本号相同，比较release_date")
    
    # 如果两个日期都存在，使用compare_version_dates进行比较
    if current_date and sandbox_date:
        try:
            date_comparison = compare_version_dates(sandbox_date, current_date)
            # compare_version_dates返回：1表示sandbox更新，-1表示current更新，0表示相同
            if date_comparison == 1:
                logger.info(f"release_date不同：sandbox ({sandbox_date}) > current ({current_date})，需要更新")
                return True
            elif date_comparison == -1:
                logger.info(f"release_date不同：current ({current_date}) > sandbox ({sandbox_date})，无需更新")
                return False
            else:
                logger.info(f"release_date相同，无需更新")
                return False
        except Exception as e:
            logger.warning(f"比较release_date失败：{e}，视为无更新")
            return False
    # 如果只有sandbox有日期，视为有更新（新版本有日期信息）
    elif sandbox_date and not current_date:
        logger.info(f"sandbox有release_date而current没有，视为有更新")
        return True
    # 如果只有current有日期或都没有日期，视为无更新
    else:
        logger.info(f"release_date信息不足，版本号相同，视为无更新")
        return False

# 检查三方应用是否安装了，安装了才需要更新
def recheck_thirdparty_modules(other_modules):
    """
    筛选已安装的三方应用模块：
    已安装条件：
    1. /opt/sysb/modules/third_party_app/[应用名] 文件夹存在（排除未安装过的情况）
    2. 该文件夹下存在有效的 `latest` 软链接（排除已卸载的情况）
    
    Args:
        other_modules: 待检查的三方应用模块列表（元素为应用名称，如 ["dwdeploy_lite", "xxx_app"]）
    
    Returns:
        list: 已安装的三方应用模块列表（筛选后的结果）
    """
    if not isinstance(other_modules, list) or len(other_modules) == 0:
        logger.info("待检查的三方应用列表为空，直接返回空列表")
        return []
    
    other_modules_filtered = []
    
    for app_display_name in other_modules:
        if not isinstance(app_display_name, str) or app_display_name.strip() == "":
            logger.warning(f"跳过无效的应用名称：{app_display_name}（非字符串或为空）")
            continue
        app_name = MODULE_NAME_MAPPING.get(app_display_name, app_display_name)
        app_name = app_name.strip()
        
        # 1. 构建三方应用的根目录（字符串）
        app_root_dir = os.path.join(THIRD_PARTY_ROOT, app_name)
        
        # 2. 检查条件1：应用根目录是否存在（改用 os.path 方法）
        if not os.path.exists(app_root_dir) or not os.path.isdir(app_root_dir):
            logger.info(f"三方应用 {app_name}：未安装（根目录不存在 {app_root_dir}），无需更新")
            continue
        
        # 3. 检查条件2：是否存在有效的 latest 软链接（改用 os.path 方法）
        latest_symlink = os.path.join(app_root_dir, "latest")
        # 软链接存在 + 是符号链接 + 链接指向有效目录
        if (os.path.exists(latest_symlink) and 
            os.path.islink(latest_symlink) and 
            os.path.exists(os.path.realpath(latest_symlink))):  # os.path.realpath 解析软链接
            
            # 验证软链接指向的是该应用下的版本目录
            link_target = os.path.realpath(latest_symlink)
            if os.path.dirname(os.path.dirname(link_target)) == app_root_dir:
                logger.info(f"三方应用 {app_name}：已安装（软链接 {latest_symlink} 指向 {link_target}），需要更新")
                other_modules_filtered.append(app_display_name)
            else:
                logger.warning(f"三方应用 {app_name}：软链接指向异常（{latest_symlink} → {link_target} 不属于该应用目录），视为未安装")
        else:
            if os.path.exists(latest_symlink) and not os.path.islink(latest_symlink):
                logger.warning(f"三方应用 {app_name}：存在名为 latest 的非软链接文件，视为未安装")
            elif os.path.islink(latest_symlink) and not os.path.exists(os.path.realpath(latest_symlink)):
                logger.warning(f"三方应用 {app_name}：软链接 {latest_symlink} 指向无效路径（已卸载），视为未安装")
            else:
                logger.info(f"三方应用 {app_name}：根目录存在但无有效 latest 软链接（未安装或已卸载），无需更新")
    
    logger.info(f"三方应用筛选完成：原始列表 {len(other_modules)} 个，已安装需更新的 {len(other_modules_filtered)} 个 → {other_modules_filtered}")
    return other_modules_filtered


# 检查子模块是否需要更新
def get_updated_modules(sandbox_extracted_dir, rms_dir):
    """
    比较两个目录下metadata中的所有manifest.yaml，返回指定格式的匹配成功模块列表
    
    Args:
        sandbox_extracted_dir: sandbox_metadata所在的根目录（即包含metadata文件夹的目录）
        rms_dir: rms_dir所在的根目录（即包含metadata文件夹的目录）
    
    Returns:
        list: 格式化的模块列表，格式为[{"name": str, "id": int}, ...]
    """
    # 定义名称映射规则（目录名 → 显示名称）
    NAME_MAPPING = {
        "robot_console": "Console",
        "sysb_core": "System B",
        "dwdeploy_lite": "DWdeploy"
    }
    # 固定开头的系统依赖项
    SYSTEM_DEPENDENCIES = "System Dependencies"
    
    # 定义两个metadata的根路径
    sandbox_metadata_root = os.path.join(sandbox_extracted_dir, "metadata")
    sysb_metadata_root = os.path.join(rms_dir, "metadata")
    
    # 验证两个metadata目录是否存在
    if not os.path.exists(sandbox_metadata_root):
        raise FileNotFoundError(f"sandbox metadata目录不存在: {sandbox_metadata_root}")
    if not os.path.exists(sysb_metadata_root):
        raise FileNotFoundError(f"sysb metadata目录不存在: {sysb_metadata_root}")
    
    # 存储匹配成功的原始模块名称（去重）
    matched_module_names = set()

    # 遍历sandbox_metadata下的所有manifest.yaml（递归处理third_party_app的多个子目录）
    for root, dirs, files in os.walk(sandbox_metadata_root):
        for file in files:
            if file == "manifest.yaml":
                # 获取sandbox侧的manifest路径
                sandbox_manifest_path = os.path.join(root, file)
                
                # 计算相对于metadata根目录的相对路径（用于匹配sysb侧的文件）
                rel_path = os.path.relpath(sandbox_manifest_path, sandbox_metadata_root)
                
                # 获取sysb侧对应的manifest路径
                sysb_manifest_path = os.path.join(sysb_metadata_root, rel_path)
                
                # 检查sysb侧是否存在对应的manifest文件
                if not os.path.exists(sysb_manifest_path):
                    continue  # 跳过sysb侧不存在的文件
                logger.info("comparing %s and %s", sysb_manifest_path, sandbox_manifest_path)
                # 调用比较方法
                if compare_manifest_yaml(sysb_manifest_path, sandbox_manifest_path):
                    # 获取模块原始目录名称
                    module_dir = os.path.dirname(sandbox_manifest_path)
                    original_module_name = os.path.basename(module_dir)
                    logger.info(f"Matched module: {original_module_name}")
                    matched_module_names.add(original_module_name)

    # 步骤1：转换为显示名称（应用映射规则）
    display_names = []
    for original_name in matched_module_names:
        # 优先使用映射名称，无映射则保留原始名称
        display_name = NAME_MAPPING.get(original_name, original_name)
        logger.info("original_name: %s, display_name: %s", original_name, display_name)
        display_names.append(display_name)
    
    # 步骤2：构建最终列表（固定开头 + 排序后的其他模块）
    # 先分离固定映射的模块（确保顺序），再处理其他模块
    console_exists = "Console" in display_names
    system_b_exists = "System B" in display_names
    other_modules = [name for name in display_names if name not in ["Console", "System B"]]
    logger.info("other_modules before sorting: %s", other_modules)
    # 排序其他模块（按字母顺序，保证一致性）
    other_modules.sort()

    other_modules_filtered = recheck_thirdparty_modules(other_modules)
    
    # 构建基础列表：System Dependencies → Console（如果存在）→ System B（如果存在）→ 其他模块
    final_name_list = [SYSTEM_DEPENDENCIES]
    if console_exists:
        final_name_list.append("Console")
    if system_b_exists:
        final_name_list.append("System B")
    final_name_list.extend(other_modules_filtered)
    
    # 步骤3：转换为指定JSON格式（id从0开始递增）
    result = [{"name": name, "id": idx} for idx, name in enumerate(final_name_list)]
    logger.info(f"Final updated modules list: {result}")

    return result

def get_modules_need_download():
    return get_updated_modules(sandbox_extracted_dir, rms_dir)

def get_modules_need_updatesh():
    """
    获取待执行update.sh的模块列表（移除System Dependencies条目）
    Returns:
        list: 移除System Dependencies后的模块列表，格式[{"name": str, "id": int}, ...]
    """
    # 获取完整模块列表
    full_module_list = get_updated_modules(sandbox_extracted_dir, rms_dir)
    if not full_module_list:
        logger.info("待更新模块列表为空，直接返回空列表")
        return []
    
    # 过滤掉name为"System Dependencies"的条目（精准匹配，鲁棒性更强）
    filtered_list = [module for module in full_module_list if module.get("name") != "System Dependencies"]
    
    # 重新修正id（可选：若需要id从0重新递增，否则保留原id）
    # 若不需要修正id，直接返回filtered_list即可
    corrected_list = []
    for idx, module in enumerate(filtered_list):
        corrected_module = {
            "name": module["name"],
            "id": idx  # id从0重新排序
        }
        corrected_list.append(corrected_module)
    
    logger.info(f"移除System Dependencies后的待更新模块列表：{corrected_list}")
    return corrected_list

# 读取changelog
def get_change_log(package_dir):
    """
    读取 release_note.md 文件，提取 title 和完整内容，以字典形式返回
    
    格式规则：
    - title 从 [title] 标记后提取（如 [title]xxx → title: "xxx"）
    - 完整内容保留原始 markdown 格式（含换行、列表、空格、空行等）
    
    Args:
        package_dir: release_note.md 所在的目录路径
    
    Returns:
        dict: 包含标题和内容的字典
            - title: str，提取的标题（无则返回空字符串）
            - content: str，完整文档内容（文件不存在/读取失败则返回对应提示信息，保留字符串格式）
    """
    change_log_path = os.path.join(package_dir, "release_note.md")
    logger.info(f"Reading changelog from: {change_log_path}")
    
    # 检查文件是否存在
    if not os.path.exists(change_log_path):
        logger.info("changelog file not found")
        return {
            "title": "",
            "content": "release_note.md not found."
        }
    
    # 读取文件完整内容（关键：不做 strip()，完全保留原始格式）
    try:
        with open(change_log_path, 'r', encoding='utf-8') as file:
            # 直接 read() 不额外处理，保留所有换行、空行、首尾空格
            full_content = file.read()
    except Exception as e:
        error_msg = f"读取 release_note.md 失败：{str(e)}"
        logger.error(error_msg)
        return {
            "title": "",
            "content": error_msg
        }
    
    # 提取 title（从 [title] 标记后获取，不影响原始 content）
    title = ""
    lines = full_content.split('\n')  # 按换行分割，不改变原始内容
    for line in lines:
        line_stripped = line.strip()
        # 匹配 [title] 开头的行（不区分大小写，兼容 [Title]/[TITLE]）
        if line_stripped.lower().startswith('[title]'):
            # 提取 [title] 后的内容（去除前后空格，不影响原始 content）
            title = line_stripped[len('[title]'):].strip()
            break  # 只取第一个 [title] 标记的内容
    
    logger.info(f"Changelog title: {title}")
    # 直接返回字典，不转 JSON，content 保留原始 md 格式
    return {
        "title": title,
        "content": full_content
    }

# 检查更新接口对应调用的方法
def check_system_update():
    """
    检查系统更新，下载并比对稳定版和开发版
    
    返回:
        list: 版本比对结果列表，每个元素包含：
            - type: "stable" 或 "dev"
            - version: 版本号
            - date: 发布日期
            - update_exist: 1=有更新, -1=当前更新, 0=相同
            - updated_modules: 需要更新的模块列表
            - change_log: 更新日志内容
            - change_log_title: 更新日志标题
    """
    logger.info("check_system_update")
    global extract_info
    
    # 下载并解压两个版本的部署包到检查更新目录
    extract_info = download_and_extract_for_check()
    logger.info(f"extract_info: {extract_info}")
    
    # 获取当前系统版本
    current_version_path = os.path.join(rms_dir, "version.yaml")
    current_version_detail = get_system_version(current_version_path)
    logger.info(f"当前系统版本: {current_version_detail}")
    
    version_comparison_list = []
    no_update = True
    
    # 定义要检查的版本：(版本类型, 目录路径)
    versions_to_check = [
        ("stable", extract_info.get('stable_dir')),
        ("dev", extract_info.get('dev_dir'))
    ]
    
    for version_type, package_dir in versions_to_check:
        # 跳过不存在的版本
        if not package_dir or not os.path.exists(package_dir):
            logger.warning(f"{version_type} 版本目录不存在，跳过")
            continue
        
        version_yaml_path = os.path.join(package_dir, "version.yaml")
        logger.info(f"checking {version_type} version_yaml_path: {version_yaml_path}")
        
        # 检查version.yaml是否存在
        if not os.path.exists(version_yaml_path):
            logger.warning(f"version.yaml not found in {package_dir}")
            continue
        
        try:
            # 获取该版本的详细信息
            version_detail = get_system_version(version_yaml_path)
            # 设置版本类型（覆盖yaml中的type字段）
            version_detail["type"] = version_type
            
            # 比较版本
            update_exist = compare_version_detail(version_detail, current_version_detail)
            version_detail["update_exist"] = update_exist
            logger.info(f"{version_type} update_exist: {update_exist}")
            
            # 获取需要更新的模块列表
            updated_modules_list = get_updated_modules(package_dir, rms_dir)
            version_detail["updated_modules"] = updated_modules_list
            
            # 获取更新日志
            change_log = get_change_log(package_dir)
            version_detail["change_log"] = change_log["content"]
            version_detail["change_log_title"] = change_log["title"]
            
            logger.info(f"{version_type} version_detail: {version_detail}")
            version_comparison_list.append(version_detail)
            
            # 检查是否有更新
            if update_exist == 1:
                logger.info(f"{version_type} 版本有更新")
                no_update = False
                
        except Exception as e:
            logger.error(f"处理 {version_type} 版本时出错: {e}")
            continue
    
    # 如果没有任何版本有更新，清理临时目录
    if no_update:
        logger.info("所有版本均无更新，清理检查更新目录")
        shutil.rmtree(CHECK_UPDATE_DIR, ignore_errors=True, onerror=handle_error)
    
    return version_comparison_list

def handle_error(func, path, exc_info):
    """自定义错误处理函数"""
    print(f"Error deleting {path}: {exc_info}")

# 更新预检（是否有更新锁）
def create_update_lock() -> tuple[bool, str]:
    """
    创建升级锁文件 /var/lock/sysb_update.lock，避免并发升级
    
    逻辑说明：
    1. 检查锁文件目录是否存在，不存在则创建（适配不同系统）
    2. 使用 os.open 原子操作创建锁文件（避免race condition）
    3. 锁文件存在时直接返回失败，不存在则创建并持有文件描述符（进程退出时自动释放）
    
    Returns:
        tuple[bool, str]: (创建成功与否, 提示信息)
        - 成功：(True, "升级锁创建成功")
        - 失败：(False, "升级正在进行中，已存在升级锁，请稍后重试") 或异常信息
    """
    # 锁文件路径
    LOCK_DIR = "/var/lock"
    LOCK_FILE = os.path.join(LOCK_DIR, "sysb_update.lock")
    
    try:
        # 步骤1：确保锁文件目录存在（/var/lock可能在部分系统中不存在或权限不足）
        if not os.path.exists(LOCK_DIR):
            try:
                os.makedirs(LOCK_DIR, mode=0o755, exist_ok=True)
            except PermissionError:
                return False, f"权限不足，无法创建锁目录 {LOCK_DIR}，请以root用户执行"
        
        # 步骤2：原子操作创建锁文件（O_EXCL确保文件不存在时才创建，避免并发问题）
        # O_RDWR：读写模式；O_CREAT：文件不存在则创建；O_EXCL：与O_CREAT配合，确保原子性
        lock_fd = os.open(
            LOCK_FILE,
            os.O_RDWR | os.O_CREAT | os.O_EXCL,
            mode=0o644  # 锁文件权限：所有者读写，其他只读
        )
        
        # 步骤3：将文件描述符设置为进程退出时自动关闭（无需手动删除锁文件，进程退出即释放）
        # 注：若进程异常退出（如kill -9），系统会自动关闭文件描述符，锁文件会残留
        # 可选优化：可在程序启动时检查锁文件是否存在，若存在且对应进程已退出，可删除后重新创建
        os.close(lock_fd)  # 此处关闭后，锁文件仍存在，下次创建会失败（核心：通过文件存在性判断是否有并发）
        
        return True, "升级锁创建成功"
    
    except OSError as e:
        # 捕获文件已存在的异常（errno.EEXIST）
        if e.errno == errno.EEXIST:
            return False, "升级正在进行中，已存在升级锁，请稍后重试"
        else:
            return False, f"创建升级锁失败：{str(e)}"
    except Exception as e:
        return False, f"创建升级锁时发生未知错误：{str(e)}"

# 释放更新锁
def release_update_lock() -> tuple[bool, str]:
    """
    手动释放升级锁（可选，用于正常升级完成后清理）
    
    Returns:
        tuple[bool, str]: (释放成功与否, 提示信息)
    """
    LOCK_FILE = "/var/lock/sysb_update.lock"
    try:
        if os.path.exists(LOCK_FILE):
            os.remove(LOCK_FILE)
            return True, "升级锁已成功释放"
        else:
            return True, "升级锁不存在，无需释放"
    except PermissionError:
        return False, f"权限不足，无法删除锁文件 {LOCK_FILE}，请以root用户执行"
    except Exception as e:
        return False, f"释放升级锁失败：{str(e)}"

# endregion 检查系统更新

# region 维护运行项目

def enter_maintenance_mode() -> None:
    """
    进入维护模式：
    1. 检查是否已维护过（存在MAINTENANCE_FLAG_FILE则跳过）
    2. 获取当前运行项目信息（从.env文件读取）
    3. 若当前无运行项目，跳过记录和停止操作
    4. 若有运行项目，记录到last_running_project.txt
    5. 调用stop_project.py停止当前项目
    6. 创建维护完成标记文件，避免重复执行
    """
    # 1. 检查是否已维护过，若已维护则直接返回
    if os.path.exists(MAINTENANCE_FLAG_FILE):
        logger.info("已执行过维护模式，跳过重复操作")
        return
    
    # 2. 确保快照目录存在
    os.makedirs(SNAPSHOT_DIR, exist_ok=True)
    logger.info("开始执行维护模式：记录当前运行项目并停止服务")
    
    # 3. 获取当前运行的项目信息
    project_info = get_project_info()
    full_project_name = project_info.get("fullProjectName", "").strip()
    logger.info(f"当前运行项目信息：{project_info}")
    
    # 4. 若当前无运行项目，跳过后续操作
    if not full_project_name:
        logger.info("当前无运行的项目，无需记录和停止服务")
        # 创建标记文件（避免后续重复检查）
        with open(MAINTENANCE_FLAG_FILE, "w", encoding="utf-8") as f:
            f.write(f"maintenance_done: no_running_project\n")
        return
    
    # 5. 记录当前运行项目到快照文件
    try:
        # 格式：fullProjectName: rmsVersion（如 demo_v1.0.2: v1.0.2）
        rms_version = project_info.get("rmsVersion", "").strip() or "unknown"
        with open(LAST_RUNNING_PROJECT_FILE, "w", encoding="utf-8") as f:
            f.write(f"{full_project_name}: {rms_version}\n")
        logger.info(f"已记录当前运行项目到：{LAST_RUNNING_PROJECT_FILE}")
    except Exception as e:
        raise RuntimeError(f"记录运行项目失败：{str(e)}") from e
    
    # 6. 调用stop_project.py停止当前项目
    if not os.path.exists(STOP_PROJECT_SCRIPT):
        raise FileNotFoundError(f"停止项目脚本不存在：{STOP_PROJECT_SCRIPT}")
    
    try:
        logger.info(f"开始停止当前项目：{full_project_name}，调用脚本：{STOP_PROJECT_SCRIPT}")
        # 执行停止脚本（实时输出日志，捕获错误）
        result = subprocess.run(
            ["python3", STOP_PROJECT_SCRIPT, '--stop-all'],
            check=True,
            capture_output=True,
            text=True,
            timeout=300  # 超时时间5分钟（可根据脚本实际执行时间调整）
        )
        logger.info(f"停止项目成功，脚本输出：\n{result.stdout}")
    except subprocess.CalledProcessError as e:
        raise RuntimeError(f"停止项目脚本执行失败（返回码：{e.returncode}）：\n{e.stderr}") from e
    except subprocess.TimeoutExpired:
        raise RuntimeError(f"停止项目脚本执行超时（超过5分钟）") from None
    except Exception as e:
        raise RuntimeError(f"停止项目异常：{str(e)}") from e
    
    # 7. 创建维护完成标记文件，避免重复执行
    try:
        with open(MAINTENANCE_FLAG_FILE, "w", encoding="utf-8") as f:
            f.write(f"maintenance_done: {full_project_name}\n")
            f.write(f"timestamp: {subprocess.check_output(['date', '+%Y-%m-%d %H:%M:%S']).decode('utf-8').strip()}\n")
        logger.info(f"维护模式执行完成，已创建标记文件：{MAINTENANCE_FLAG_FILE}")
    except Exception as e:
        raise RuntimeError(f"创建维护标记文件失败：{str(e)}") from e

# endregion

# 用于标记是否已经应用了选中的版本（避免重复执行）
_version_applied = False
# 记录当前应用的版本类型（用于检测版本切换）
_applied_version_type = None

# region 下载更新
def download_system_update_package(module_id: int, module_name: str, system_type: str = "stable") -> bool:
    """
    下载系统更新包：
    - System Dependencies（id=0）：先将选中版本部署到位，再调用INSTALL_DEPENDENCIES.sh脚本
    - 其他模块（Console/System B/DWdeploy等）：通过pull_from_cloud.py拉取manifest对应的资源
    
    Args:
        module_id: 模块ID（0=System Dependencies, 1=Console, 2=System B, 3=DWdeploy...）
        module_name: 模块名称（对应显示名称）
        system_type: 版本类型，"stable"（稳定版）或 "dev"（开发版）
    
    Returns:
        bool: 下载成功返回True，失败返回False
    """
    global _version_applied, _applied_version_type
    
    # 执行维护模式处理
    try:
        enter_maintenance_mode()  # 进入维护模式：记录项目→停止项目
    except Exception as e:
        logger.error(f"进入维护模式失败：{str(e)}", exc_info=True)
        raise RuntimeError(f"维护模式初始化失败：{str(e)}") from e
    
    # 场景1：System Dependencies（id=0 或名称匹配）—— 首个模块
    if module_name == "System Dependencies" or module_id == 0:
        logger.info(f"Start installing system dependencies (module_id={module_id}, module_name={module_name}, system_type={system_type})")
        
        # 检查是否需要应用版本：
        # 1. 首次下载（_version_applied=False）
        # 2. 版本类型发生变化（用户切换了 stable/dev）
        need_apply = not _version_applied or _applied_version_type != system_type
        
        if need_apply:
            if _applied_version_type and _applied_version_type != system_type:
                logger.info(f"检测到版本类型变化: {_applied_version_type} → {system_type}，重新应用版本")
            else:
                logger.info(f"首次下载模块，应用用户选择的 {system_type} 版本")
            try:
                apply_selected_version(system_type)
                _version_applied = True
                _applied_version_type = system_type
                logger.info(f"成功应用 {system_type} 版本到 {FINAL_DEPLOYMENT_DIR}")
            except Exception as e:
                logger.error(f"应用 {system_type} 版本失败: {e}")
                raise RuntimeError(f"应用选中版本失败：{e}") from e
        
        return _execute_dependency_script()
    
    # 场景2：其他模块（Console/System B/DWdeploy等）
    logger.info(f"Start pulling module resources: {module_name} (module_id={module_id})")
    return _pull_module_resources(module_name)


def reset_version_applied_flag():
    """
    重置版本应用标记（供清理环境时调用）
    """
    global _version_applied, _applied_version_type
    _version_applied = False
    _applied_version_type = None
    logger.info("已重置版本应用标记")

def _execute_dependency_script() -> bool:
    """执行System Dependencies对应的依赖安装脚本"""
    # 检查脚本是否存在
    if not os.path.exists(DEPENDENCY_SCRIPT_PATH):
        error_msg = f"依赖安装脚本不存在：{DEPENDENCY_SCRIPT_PATH}"
        logger.error(error_msg)
        raise FileNotFoundError(error_msg)  # 抛出异常，由任务线程捕获
    
    # 检查脚本执行权限
    if not os.access(DEPENDENCY_SCRIPT_PATH, os.X_OK):
        logger.warning(f"脚本缺少执行权限，尝试添加：{DEPENDENCY_SCRIPT_PATH}")
        try:
            subprocess.run(
                ["chmod", "+x", DEPENDENCY_SCRIPT_PATH],
                check=True,
                capture_output=True,
                text=True
            )
        except subprocess.CalledProcessError as e:
            error_msg = f"添加脚本执行权限失败：{e.stderr}"
            logger.error(error_msg)
            raise PermissionError(error_msg)
    
    # 执行脚本（捕获输出日志，便于排查）
    logger.info(f"开始执行依赖安装脚本：{DEPENDENCY_SCRIPT_PATH}")
    try:
        result = subprocess.run(
            ["/bin/bash", DEPENDENCY_SCRIPT_PATH],  # 使用bash执行shell脚本
            check=True,
            capture_output=True,
            text=True,
            timeout=3600  # 超时时间1小时（可根据脚本实际执行时间调整）
        )
        logger.info(f"依赖安装脚本执行成功：\n{result.stdout}")
        return True
    except subprocess.CalledProcessError as e:
        error_msg = f"依赖安装脚本执行失败（返回码：{e.returncode}）：\n{e.stderr}"
        logger.error(error_msg)
        raise RuntimeError(error_msg)
    except subprocess.TimeoutExpired:
        error_msg = f"依赖安装脚本执行超时（超过1小时）"
        logger.error(error_msg)
        raise TimeoutError(error_msg)
    except Exception as e:
        error_msg = f"执行依赖安装脚本异常：{str(e)}"
        logger.error(error_msg)
        raise RuntimeError(error_msg)

def _pull_module_resources(module_name: str) -> bool:
    """通过pull_from_cloud.py拉取模块对应的资源（镜像/tar包）"""
    # 1. 映射模块显示名称到metadata实际目录名
    actual_dir = MODULE_NAME_MAPPING.get(module_name)
    logger.info(f"Mapping module name '{module_name}' to actual directory '{actual_dir}'")
    if not actual_dir:
        error_msg = f"未配置模块映射关系：{module_name}（需在MODULE_NAME_MAPPING中添加）"
        logger.error(error_msg)
        raise KeyError(error_msg)
    
    # 2. 构建manifest.yaml的完整路径
    if actual_dir not in ["sysb_core", "robot_console"]:
        manifest_path = os.path.join(METADATA_ROOT, "third_party_app", actual_dir, "manifest.yaml")
        logger.info("actual_dir not in sysb_core or robot_console, manifest_path: %s", manifest_path)
    else:
        manifest_path = os.path.join(METADATA_ROOT, actual_dir, "manifest.yaml")
        logger.info("actual_dir in sysb_core or robot_console, manifest_path: %s", manifest_path)
    if not os.path.exists(manifest_path):
        error_msg = f"模块manifest文件不存在：{manifest_path}"
        logger.error(error_msg)
        raise FileNotFoundError(error_msg)
    
    # 3. 检查拉取脚本是否存在
    if not os.path.exists(PULL_SCRIPT_PATH):
        error_msg = f"资源拉取脚本不存在：{PULL_SCRIPT_PATH}"
        logger.error(error_msg)
        raise FileNotFoundError(error_msg)

    # 在检查脚本存在后添加权限检查
    if not os.access(PULL_SCRIPT_PATH, os.X_OK):
        logger.warning(f"pull_from_cloud.py 缺少执行权限，尝试添加：{PULL_SCRIPT_PATH}")
        subprocess.run(
            ["chmod", "+x", PULL_SCRIPT_PATH],
            check=True,
            capture_output=True,
            text=True
        )
    
    # 4. 执行拉取脚本（优化后：保留实时输出，完整捕获日志）
    logger.info(f"开始拉取模块资源：{module_name}（manifest：{manifest_path}）")
    try:
        process = subprocess.Popen(
            ["python3", PULL_SCRIPT_PATH, manifest_path],
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,  # 合并标准输出和错误输出
            text=True,
            bufsize=1,  # 行缓冲，实时打印日志
            universal_newlines=True  # 自动转换换行符
        )
        
        # 实时读取脚本输出，写入日志（保留进度信息）
        stdout_lines = []
        if process.stdout:
            for line in iter(process.stdout.readline, ''):
                line = line.strip()
                if line:
                    stdout_lines.append(line)
                    logger.info(f"pull_from_cloud 输出：{line}")  # 实时打印到日志
        
        # 等待脚本执行完成，获取返回码
        process.wait()
        returncode = process.returncode
        
        # 检查返回码，判断执行结果
        if returncode != 0:
            error_msg = f"模块{module_name}资源拉取失败（返回码：{returncode}），详细日志：\n{chr(10).join(stdout_lines)}"
            logger.error(error_msg)
            raise RuntimeError(error_msg)
        
        # 执行成功，打印完整日志
        logger.info(f"模块{module_name}资源拉取成功，完整输出：\n{chr(10).join(stdout_lines)}")
        return True
        
    except subprocess.TimeoutExpired:
        error_msg = f"模块{module_name}资源拉取超时（超过2小时）"
        logger.error(error_msg)
        raise TimeoutError(error_msg)
    except Exception as e:
        # 捕获其他异常（如脚本不存在、权限不足等）
        error_msg = f"拉取模块{module_name}资源异常：{str(e)}"
        logger.error(error_msg)
        raise RuntimeError(error_msg)

def call_extract_script(module_name: str) -> bool:
    """调用 extract_modules.py 解压 host_package，返回执行结果"""
    # 1. 获取模块对应的manifest路径（与pull_from_cloud保持一致）
    actual_dir = MODULE_NAME_MAPPING.get(module_name)
    if not actual_dir:
        error_msg = f"未配置模块映射关系：{module_name}"
        logger.error(error_msg)
        raise KeyError(error_msg)
    
    if actual_dir not in ["sysb_core", "robot_console"]:
        manifest_path = os.path.join(METADATA_ROOT, "third_party_app", actual_dir, "manifest.yaml")
        logger.info("actual_dir not in sysb_core or robot_console, manifest_path: %s", manifest_path)
    else:
        manifest_path = os.path.join(METADATA_ROOT, actual_dir, "manifest.yaml")
        logger.info("actual_dir in sysb_core or robot_console, manifest_path: %s", manifest_path)
    if not os.path.exists(manifest_path):
        error_msg = f"模块manifest文件不存在：{manifest_path}"
        logger.error(error_msg)
        raise FileNotFoundError(error_msg)
    
    # 2. 检查解压脚本是否存在
    if not os.path.exists(EXTRACT_SCRIPT_PATH):
        error_msg = f"解压脚本不存在：{EXTRACT_SCRIPT_PATH}"
        logger.error(error_msg)
        raise FileNotFoundError(error_msg)
    
    # 3. 检查脚本执行权限
    if not os.access(EXTRACT_SCRIPT_PATH, os.X_OK):
        logger.warning(f"extract_modules.py 缺少执行权限，尝试添加")
        subprocess.run(
            ["chmod", "+x", EXTRACT_SCRIPT_PATH],
            check=True,
            capture_output=True,
            text=True
        )
    
    # 4. 执行解压脚本（类似pull_from_cloud的调用方式，实时输出日志）
    logger.info(f"开始解压模块资源：{module_name}（manifest：{manifest_path}）")
    try:
        process = subprocess.Popen(
            ["python3", EXTRACT_SCRIPT_PATH, manifest_path],
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            bufsize=1,
            universal_newlines=True
        )
        
        stdout_lines = []
        if process.stdout:
            for line in iter(process.stdout.readline, ''):
                line = line.strip()
                if line:
                    stdout_lines.append(line)
                    logger.info(f"extract_modules 输出：{line}")
        
        process.wait()
        returncode = process.returncode
        
        if returncode != 0:
            error_msg = f"模块{module_name}解压失败（返回码：{returncode}），详细日志：\n{chr(10).join(stdout_lines)}"
            logger.error(error_msg)
            raise RuntimeError(error_msg)
        
        logger.info(f"模块{module_name}解压成功")
        return True
        
    except subprocess.TimeoutExpired:
        error_msg = f"模块{module_name}解压超时（超过1小时）"
        logger.error(error_msg)
        raise TimeoutError(error_msg)
    except Exception as e:
        error_msg = f"解压模块{module_name}资源异常：{str(e)}"
        logger.error(error_msg)
        raise RuntimeError(error_msg)

# endregion

# region 应用更新（调用update.sh脚本）

def check_is_final_module(module_name):
    logger.info("Entered method check_is_final_module")
    module_list = get_modules_need_updatesh()
    last_module_name = module_list[-1]
    logger.info(f"module_list: {module_list}, last_module_name: {last_module_name}")
    if module_name == last_module_name['name']:
        logger.info("is last module")
        return True
    else:
        logger.info("NOT last module")
        return False

def get_update_project_project_param() -> Optional[str]:
    """
    从维护模式快照文件/环境变量中提取update_project.py的--project入参
    适配维护模式中「无运行项目」的场景
    
    Returns:
        Optional[str]: 项目名称/路径（如 demo1 或 /opt/sysb/project/demo1_v1.0.0）；
                      无有效项目时返回 None（而非空字符串）
    """
    project_param: Optional[str] = None

    # 1. 优先从维护模式快照文件读取
    if os.path.exists(LAST_RUNNING_PROJECT_FILE):
        try:
            with open(LAST_RUNNING_PROJECT_FILE, "r", encoding="utf-8") as f:
                line = f.readline().strip()
                if line and ":" in line:
                    # 提取完整项目名（如 demo1_v1.0.0）作为--project参数
                    project_name_part = line.split(":", 1)[0].strip()
                    # 排除维护模式中标记的「无运行项目」场景
                    if project_name_part.lower() != "no_running_project":
                        project_param = project_name_part
                        logger.info(f"从快照文件读取到项目参数：{project_param}")
                    else:
                        logger.info("快照文件标记为无运行项目，跳过快照文件读取")
                else:
                    logger.warning("快照文件格式异常（无:分隔符），将尝试从.env兜底")
        except Exception as e:
            logger.error(f"读取快照文件异常：{str(e)}，将尝试从.env兜底")
    
    # 2. 快照文件无有效数据时，从.env文件兜底
    if not project_param:
        try:
            project_info = get_project_info()
            project_param = project_info.get("fullProjectName", "").strip()
            if project_param:
                logger.info(f"从.env文件读取到项目参数：{project_param}")
            else:
                logger.warning("update_project.py 核心入参 --project 缺失：.env文件无项目信息")
        except Exception as e:
            logger.error(f"读取.env文件异常：{str(e)}，项目参数获取失败")
    
    # 3. 最终校验：无有效项目时明确返回None，并记录日志
    if not project_param:
        logger.error("update_project.py 核心入参 --project 最终获取失败：无可用的项目信息")
    return project_param

def get_module_version(module_metadata_dir: str) -> str:
    """从metadata对应模块的manifest.yaml中读取版本号"""
    if module_metadata_dir in ["robot_console", "sysb_core"]:
        manifest_path = os.path.join(METADATA_ROOT, module_metadata_dir, "manifest.yaml")
    else:
        manifest_path = os.path.join(METADATA_ROOT, "third_party_app", module_metadata_dir, "manifest.yaml")

    if not os.path.exists(manifest_path):
        raise FileNotFoundError(f"manifest文件不存在：{manifest_path}")
    
    with open(manifest_path, "r", encoding="utf-8") as f:
        manifest_data = yaml.safe_load(f) or {}
        version = manifest_data.get("version")
        if not version or not isinstance(version, str):
            raise ValueError(f"{manifest_path} 中未配置有效的version字段")
    return version

def get_update_script_path(module_name: str) -> tuple[str, str]:
    """
    根据模块名称获取更新脚本路径：
    返回：(主脚本路径, 前置脚本路径) → 前置脚本仅Console模块有
    逻辑说明：
    - Console/System B：需通过 MODULE_NAME_MAPPING 映射 metadata 目录名
    - 其他三方应用：直接使用前端传入的 name 作为 metadata 目录名，无需映射
    """
    # 1. 区分模块类型，获取 metadata 目录名
    if module_name in ["Console", "System B", "DWdeploy"]:
        metadata_dir = MODULE_NAME_MAPPING.get(module_name)
        if not metadata_dir:
            raise KeyError(f"未配置系统模块映射关系：{module_name}（需在MODULE_NAME_MAPPING中添加）")
    else:
        metadata_dir = module_name.strip()
        if not metadata_dir:
            raise ValueError("三方应用名称不能为空")
        logger.info(f"三方应用 {module_name}：直接使用名称作为 metadata 目录名 → {metadata_dir}")
    
    # 2. 读取manifest中的版本号
    version = get_module_version(metadata_dir)
    logger.info(f"模块 {module_name} 对应的版本号：{version}")
    
    # 3. 按模块类型生成脚本路径
    if module_name == "Console":
        main_script_path = os.path.join(MODULES_ROOT, "robot_console", version, "robot_console", "update.sh")
    elif module_name == "System B":
        main_script_path = SYSTEM_B_SCRIPT
    else:
        third_party_app_name = MODULE_NAME_MAPPING.get(module_name)
        main_script_path = os.path.join(
            MODULES_ROOT, "third_party_app", third_party_app_name, version, third_party_app_name, "update.sh"
        )
    
    # 4. 校验主脚本是否存在
    if not os.path.exists(main_script_path):
        raise FileNotFoundError(f"更新脚本不存在：{main_script_path}")
    
    return main_script_path

def execute_script(script_path: str, script_type: str = "main", module_name: str = "") -> None:
    """
    执行脚本（实时输出日志，抛出异常）
    Args:
        script_path: 脚本路径
        script_type: 脚本类型（main/update_sysb_dir）
        module_name: 模块名称（用于区分System B，传递入参）
    """
    logger.info(f"开始执行{script_type}脚本：{script_path}")
    
    # 检查脚本执行权限（.sh脚本需要，.py脚本可选）
    if script_path.endswith(".sh") and not os.access(script_path, os.X_OK):
        logger.warning(f"{script_type}脚本缺少执行权限，尝试添加：{script_path}")
        subprocess.run(
            ["chmod", "+x", script_path],
            check=True,
            capture_output=True,
            text=True
        )
    
    # 构建执行命令（核心：适配System B的update_project.py入参）
    if script_path == UPDATE_PROJECT_PY:
        # System B：update_project.py 仅需 --project 参数
        try:
            # 获取--project参数值
            project_param = get_update_project_project_param()
            # 构建命令行（python3 + 脚本路径 + --project=项目名）
            if not project_param:
                logger.info("没有维护运行项目，无需调用update_project.py")
                return
            cmd = ["python3", script_path, f"--project={project_param}"]
            logger.info(f"构建update_project.py执行命令：{' '.join(cmd)}")
        except Exception as e:
            raise RuntimeError(f"构建update_project.py入参失败：{str(e)}") from e
    else:
        # 其他脚本：按后缀执行（Console/三方应用的.sh脚本）
        if script_path.endswith(".sh"):
            cmd = ["/bin/bash", script_path]
        elif script_path.endswith(".py"):
            cmd = ["python3", script_path]
        else:
            raise RuntimeError(f"不支持的脚本类型：{script_path}（仅支持.sh和.py）")
    update_script_dir = os.path.dirname(script_path)
    # 执行脚本（实时输出日志）
    process = subprocess.Popen(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        bufsize=1,
        universal_newlines=True,
        cwd=update_script_dir
    )
    
    stdout_lines = []
    if process.stdout:
        for line in process.stdout:
            line = line.rstrip('\n\r')  # strip 换行符，保留空格等其他内容
            if line:  # 可选：跳过空行
                stdout_lines.append(line)
                logger.info(f"[{script_type}_script] {line}")

    process.wait()
    returncode = process.returncode
    
    if returncode != 0:
        error_msg = f"{script_type}脚本执行失败（返回码：{returncode}），日志：\n{chr(10).join(stdout_lines)}"
        logger.error(error_msg)
        raise RuntimeError(error_msg)
    
    logger.info(f"{script_type}脚本执行成功：{script_path}")

# endregion 应用更新（调用update.sh脚本）

# region 更新后清理环境

def cleanup_sysb_environment():
    """
    清理SYSB升级相关环境：
    1. 清理/opt/sandbox目录内的所有内容（保留sandbox文件夹本身）
    2. 删除/opt下的sysb.back-*和sysb.new相关临时目录
    3. 释放升级锁文件/var/lock/sysb_update.lock

    Raises:
        PermissionError: 无权限操作文件/目录时抛出
        OSError: 系统级文件操作异常时抛出
    """
    # ========== 定义清理目标路径 ==========
    SANDBOX_DIR = "/opt/sandbox"
    BACKUP_DIR_PATTERNS = ["/opt/sysb.back-*"]  # 匹配sysb.back-20251206-100000这类目录
    NEW_DIR_PATTERNS = ["/opt/sysb.new", "/opt/sysb.new-*"]  # 匹配sysb.new或带时间戳的new目录

    # ========== 1. 清理/opt/sandbox（保留文件夹本身） ==========
    logger.info("开始清理/opt/sandbox目录内容...")
    if os.path.exists(SANDBOX_DIR):
        # 遍历sandbox内所有内容
        for item_name in os.listdir(SANDBOX_DIR):
            item_path = os.path.join(SANDBOX_DIR, item_name)
            try:
                if os.path.isfile(item_path) or os.path.islink(item_path):
                    # 删除文件/软链接
                    os.remove(item_path)
                    logger.info(f"已删除文件/链接: {item_path}")
                elif os.path.isdir(item_path):
                    # 删除目录（包含所有子内容），处理只读文件
                    def remove_readonly(func, path, excinfo):
                        os.chmod(path, stat.S_IWRITE)
                        func(path)
                    
                    shutil.rmtree(item_path, onerror=remove_readonly)
                    logger.info(f"已删除目录: {item_path}")
            except Exception as e:
                logger.error(f"清理{item_path}失败: {str(e)}", exc_info=True)
                raise
    else:
        logger.warning("/opt/sandbox目录不存在，跳过清理")

    # ========== 2. 删除sysb.back-*和sysb.new相关临时目录 ==========
    all_temp_patterns = BACKUP_DIR_PATTERNS + NEW_DIR_PATTERNS
    for pattern in all_temp_patterns:
        logger.info(f"开始清理匹配{pattern}的临时目录...")
        for dir_path in glob.glob(pattern):
            if os.path.isdir(dir_path):
                try:
                    # 处理只读文件的onerror回调
                    def remove_readonly(func, path, excinfo):
                        os.chmod(path, stat.S_IWRITE)
                        func(path)
                    
                    shutil.rmtree(dir_path, onerror=remove_readonly)
                    logger.info(f"已删除临时目录: {dir_path}")
                except Exception as e:
                    logger.error(f"删除{dir_path}失败: {str(e)}", exc_info=True)
                    raise
            else:
                logger.warning(f"{dir_path}不是目录，跳过删除")
    
    # ========== 3. 释放升级锁文件 ==========
    logger.info("开始释放升级锁文件...")
    release_update_lock()

    if os.path.exists(install_status_path):
        os.remove(install_status_path)

    # ========== 4. 重置版本应用标记 ==========
    reset_version_applied_flag()

    logger.info("SYSB环境清理完成 ✅")


# endregion

# endregion 系统更新

# region 系统更新已废弃老逻辑

# region 获取rms镜像拉取进度
# 获取rms镜像拉取进度详细信息
def get_rms_pull_progress() -> Optional[Dict[str, Any]]:
    """
    实时获取RMS镜像拉取进度
    
    Returns:
        包含进度信息的字典，包括:
        - overall_progress: 总体进度百分比
        - status: 任务状态 (running/completed/failed)
        - completed_images: 已完成的镜像数量
        - total_images: 总镜像数量
    """
    if not os.path.exists(PROGRESS_FILE):
        return None
    
    try:
        with open(PROGRESS_FILE, 'r') as f:
            progress_data = json.load(f)
        return progress_data
    except Exception as e:
        print(f"读取进度文件失败: {e}")
        return None
    
# 获取rms总的镜像拉取进度百分比
def get_live_progress_percentage() -> float:
    """
    获取实时拉取进度的总百分比
    
    Returns:
        总进度百分比，范围0-100
    """
    progress_data = get_rms_pull_progress()
    if not progress_data:
        return 0.0
    
    return progress_data.get('overall_progress', 0.0)

# endregion 获取rms镜像拉取进度

# region 立即更新
# 系统更新时，调用安装依赖和清理过期数据的脚本
def run_install_dependency():
    # 顺序执行两个脚本：INSTALL-DEPANDENCES.sh 与 CLEAN-EXPIRED-DATA.sh
    scripts_to_run = [
        'INSTALL-DEPANDENCES.sh',
        'CLEAN-EXPIRED-DATA.sh'
    ]

    for script_name in scripts_to_run:
        script_path = os.path.join(script_dir, script_name)
        logger.info("script_path {}".format(script_path))
        command = ['bash', script_path]
        print(command)
        logger.info("command {}".format(command))

        # 即使失败也继续执行下一个脚本：不使用 check=True
        try:
            result = subprocess.run(
                command,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                universal_newlines=True,
                check=False
            )
            print('Return code:', result.returncode)
            logger.info('Return code: %s', result.returncode)
            if result.returncode == 0:
                print('Output:', result.stdout)
                logger.info('Return message: %s', result.stdout)
            else:
                # 将错误输出到日志并继续执行下一个脚本
                print('Error Output:', result.stderr)
                logger.error('Return message: %s', result.stderr)
        except Exception as e:
            # 捕获非 CalledProcessError 异常，记录并继续
            print(f"Error executing command: {command} with error msg: {str(e)}")
            logger.error(f"Error executing command: {command} with error msg: {str(e)}")

def merge_dw_ui_config(old_file_path, new_file_path):
    """
    合并两个配置文件，将老版本文件中分界线以上的内容拷贝到新版本文件中
    
    Args:
        old_file_path: 老版本配置文件路径
        new_file_path: 新版本配置文件路径
    """
    try:
        # 读取老版本文件中需要拷贝的部分（分界线以上）
        with open(old_file_path, 'r', encoding='utf-8') as old_file:
            lines = old_file.readlines()
            
            # 查找分界线位置
            separator_index = None
            for i, line in enumerate(lines):
                if "不需修改" in line:
                    separator_index = i
                    break
            
            # 提取分界线以上的内容（包含分界线行）
            if separator_index is not None:
                old_lines_to_copy = lines[:separator_index + 1]
            else:
                old_lines_to_copy = lines  # 若未找到分界线，拷贝全部内容
        
        # 读取新版本文件
        with open(new_file_path, 'r', encoding='utf-8') as new_file:
            new_lines = new_file.readlines()
        
        # 查找新版本中的分界线位置
        new_separator_index = None
        for i, line in enumerate(new_lines):
            if "不需修改" in line:
                new_separator_index = i
                break
        
        # 合并文件内容
        if new_separator_index is not None:
            # 用老版本分界线以上的内容替换新版本分界线以上的内容
            updated_lines = old_lines_to_copy + new_lines[new_separator_index + 1:]
        else:
            # 若新版本未找到分界线，直接追加老版本内容
            updated_lines = old_lines_to_copy + new_lines
        
        # 写回修改后的新版本文件
        with open(new_file_path, 'w', encoding='utf-8') as new_file:
            new_file.writelines(updated_lines)
            
        print(f"成功合并配置文件: {old_file_path} -> {new_file_path}")
        
    except Exception as e:
        print(f"合并过程中发生错误: {str(e)}")

def copy_point_sync_shell_old_config(old_shell_path, new_shell_path):
    """
    仅将旧shell脚本“开头默认变量”（pi_protocol/dw_ip/pi_ip等）的值拷贝到新脚本，
    保留新脚本其他结构（函数、命令解析等）不变。

    Args:
        old_shell_path (str): 旧shell脚本路径
        new_shell_path (str): 新shell脚本路径
    """
    try:
        # --------------------------
        # 步骤1：提取旧脚本的“开头默认变量”
        # --------------------------
        old_start_vars = {}  # 存储旧脚本开头的默认变量（key:变量名, value:变量值）
        var_pattern = re.compile(r'^\s*(\w+)\s*=\s*(.*?)\s*$')  # 匹配变量行（如 pi_ip="192.168.1.1"）
        in_start_var_section = False  # 标记是否处于“开头默认变量区”
        start_var_end_marker = False   # 标记是否退出“开头默认变量区”

        with open(old_shell_path, 'r', encoding='utf-8') as old_file:
            for line in old_file:
                stripped_line = line.strip()

                # 1. 触发“开头默认变量区”：遇到“# 设置默认变量值”注释
                if not in_start_var_section and stripped_line.startswith('# 设置默认变量值'):
                    in_start_var_section = True
                    continue

                # 2. 退出“开头默认变量区”：变量区结束（遇到空行、其他注释或非变量行）
                if in_start_var_section and not start_var_end_marker:
                    # 非变量行（空行、注释、函数定义等）视为变量区结束
                    if (not stripped_line  # 空行
                        or stripped_line.startswith('#')  # 其他注释（非变量说明）
                        or not var_pattern.match(stripped_line)):  # 非变量行
                        start_var_end_marker = True
                        continue

                # 3. 提取“开头默认变量区”内的变量
                if in_start_var_section and not start_var_end_marker:
                    match = var_pattern.match(stripped_line)
                    if match:
                        var_name, var_value = match.group(1), match.group(2)
                        # 去重：若旧脚本有重复变量，使用最后一次出现的值
                        if var_name in old_start_vars:
                            logger.info(f"Warning: 旧脚本中变量 '{var_name}' 重复，使用最后一次的值")
                        old_start_vars[var_name] = var_value

        # 检查是否提取到开头变量
        if not old_start_vars:
            logger.info(f"Warning: 未从旧脚本 {old_shell_path} 中提取到开头默认变量，可能脚本结构已改变")
            return

        # --------------------------
        # 步骤2：替换新脚本的“开头默认变量”值
        # --------------------------
        new_lines = []  # 存储修改后的新脚本内容
        in_new_var_section = False  # 标记新脚本的“开头默认变量区”

        with open(new_shell_path, 'r', encoding='utf-8') as new_file:
            for line in new_file:
                stripped_line = line.strip()
                original_line = line  # 保留原始行格式（避免丢失缩进/空格）

                # 1. 进入新脚本的“开头默认变量区”
                if not in_new_var_section and stripped_line.startswith('# 设置默认变量值'):
                    in_new_var_section = True
                    new_lines.append(original_line)
                    continue

                # 2. 处理“开头默认变量区”内的行
                if in_new_var_section:
                    # 检查是否退出变量区（同旧脚本逻辑）
                    if (not stripped_line or stripped_line.startswith('#') or not var_pattern.match(stripped_line)):
                        in_new_var_section = False
                        new_lines.append(original_line)
                        continue

                    # 匹配变量行，替换为旧脚本的值
                    match = var_pattern.match(stripped_line)
                    if match:
                        var_name, new_var_value = match.group(1), match.group(2)
                        # 仅当旧脚本有该变量时才替换
                        if var_name in old_start_vars:
                            old_var_value = old_start_vars[var_name]
                            # 仅值不同时才替换（避免无意义修改）
                            if old_var_value != new_var_value:
                                # 保留变量定义的原始格式（如空格），仅替换值
                                # 示例：将 "pi_ip = 192.168.1.1" 替换为 "pi_ip = 192.168.11.100"
                                line_pattern = re.compile(r'^(\s*' + re.escape(var_name) + r'\s*=\s*).*$')
                                replaced_line = line_pattern.sub(r'\1' + old_var_value, line)
                                new_lines.append(replaced_line)
                                logger.info(f"替换变量: {var_name} -> 旧值={old_var_value}, 新值={new_var_value}")
                                continue
                    # 不满足替换条件（无旧值或值相同），保留原行
                    new_lines.append(original_line)
                    continue

                # 3. 非变量区的行（函数、命令等），直接保留
                new_lines.append(original_line)

        # --------------------------
        # 步骤3：写入修改后的内容到新脚本
        # --------------------------
        with open(new_shell_path, 'w', encoding='utf-8') as new_file:
            new_file.writelines(new_lines)

        logger.info(f"\n成功完成！已将旧脚本开头变量值同步到新脚本：")
        logger.info(f"旧脚本: {old_shell_path}")
        logger.info(f"新脚本: {new_shell_path}")
        logger.info(f"同步的变量列表: {list(old_start_vars.keys())}")

    except FileNotFoundError as e:
        logger.info(f"错误：文件不存在 - {e.filename}")
    except PermissionError:
        logger.info(f"错误：权限不足，无法读写文件（{old_shell_path} 或 {new_shell_path}）")
    except Exception as e:
        logger.info(f"未知错误：{str(e)}")


# 立即更新，覆盖部署文件夹路径
def system_update(param_dict):
    global extract_info
    extract_dir = extract_info.get('extract_dir')
    temp_dir = extract_info.get('temp_dir')
    package_files = extract_info.get('package_files')
    system_type = param_dict['type']
    if system_type == 'dev':
        extracted_package_path = os.path.join(extract_dir, 'sysb-deployment-dev')
        logger.info(f"使用开发版: {extracted_package_path}")
    elif system_type == 'stable':
        extracted_package_path = os.path.join(extract_dir, 'sysb-deployment')
    # 验证文件存在
    if not os.path.exists(extracted_package_path):
        raise FileNotFoundError(f"解压的文件夹不存在: {extracted_package_path}")
    try:
        # 解压文件
        # 存档.env
        logger.info(f"正在拷贝: {extracted_package_path} 到 {rms_dir}")
        temp_env_path = os.path.join(temp_dir, '.env')
        logger.info(f"temp_env_path: {temp_env_path}")
        shutil.copy(env_path, temp_env_path)
        # 存档POINT_SYNC.sh
        point_sync_shell_script_path = os.path.join(script_dir, 'POINT_SYNC.sh')
        temp_point_sync_shell_path = os.path.join(temp_dir, 'POINT_SYNC.sh')
        logger.info(f"point_sync_shell_script_path: {point_sync_shell_script_path}")
        logger.info(f"temp_point_sync_shell_path: {temp_point_sync_shell_path}")
        shutil.copy(point_sync_shell_script_path, temp_point_sync_shell_path)
        # 存档software.enc
        software_enc_path = os.path.join(config_tool_dir, 'software.enc')
        temp_software_enc_path = os.path.join(temp_dir, 'software.enc')
        shutil.copy(software_enc_path, temp_software_enc_path)
        logger.info(f"software_enc_path: {software_enc_path}")
        logger.info(f"temp_software_enc_path: {temp_software_enc_path}")
        
        # 使用强制复制覆盖运行中的文件
        force_copy_directory(extracted_package_path, rms_dir)

        config_tool_exe_path = os.path.join(rms_dir, 'config_tools', 'config_tool')
        chmod_cmd = f'chmod +x {config_tool_exe_path}'
        subprocess.run(chmod_cmd.split(), check=True)

        merge_dw_ui_config(temp_env_path, env_path)
        copy_point_sync_shell_old_config(temp_point_sync_shell_path, point_sync_shell_script_path)
        shutil.copy(temp_software_enc_path, software_enc_path)
        logger.info("copied software.enc from temp to config_tools")
        shutil.rmtree(temp_dir, ignore_errors=True, onerror=handle_error)
        run_install_dependency()
    except Exception as e:
        # 发生错误时清理临时文件
        logger.error(f'RMS包更新失败: {e}')
        raise Exception('RMS包更新失败')

def force_copy_directory(src, dst):
    logger.info(f"强制覆盖src: {src}, dst: {dst}")
    """使用cp -f强制复制目录，覆盖运行中的文件"""
    try:
        # 创建目标目录（如果不存在）
        os.makedirs(dst, exist_ok=True)
        
        # 遍历源目录
        for item in os.listdir(src):
            src_path = os.path.join(src, item)
            dst_path = os.path.join(dst, item)
            
            if os.path.isdir(src_path):
                # 递归处理子目录
                force_copy_directory(src_path, dst_path)
            else:
                # 强制复制文件（使用cp -f）
                subprocess.run(
                    ['cp', '-f', src_path, dst_path],
                    check=True,
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL
                )
                print(f"已强制覆盖: {dst_path}")
                
    except subprocess.CalledProcessError as e:
        raise RuntimeError(f"强制复制失败: {e.stderr}")
    except PermissionError:
        raise PermissionError("没有足够权限强制覆盖文件，请检查权限")

# endregion 立即更新

# endregion 系统更新已废弃老逻辑

# region 相机转流
def get_network_interface_list():
    """
    获取Linux系统当前可用的网络接口及其IP地址列表
    
    返回:
        list: 包含(网卡名称, IP地址)元组的列表，例如 [('eth0', '192.168.1.100'), ('lo', '127.0.0.1')]
    """
    interfaces = []
    
    try:
        # 获取所有网络接口名称
        if os.path.exists('/sys/class/net'):
            interface_names = os.listdir('/sys/class/net')
        else:
            # 备用方法获取接口名称
            output = os.popen('ip -o link show').read()
            interface_names = [re.search(r'^\d+:\s+(\S+):', line).group(1) 
                              for line in output.splitlines() if re.search(r'^\d+:\s+(\S+):', line)]
        
        # 使用socket和fcntl获取IP地址
        for ifname in interface_names:
            try:
                # 创建一个socket用于获取地址
                s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
                # 使用ioctl系统调用获取接口的IP地址
                ip = socket.inet_ntoa(fcntl.ioctl(
                    s.fileno(),
                    0x8915,  # SIOCGIFADDR
                    struct.pack('256s', ifname[:15].encode())
                )[20:24])
                interfaces.append((ifname, ip))
            except:
                # 如果无法获取该接口的IP地址，可能是没有配置IP
                interfaces.append((ifname, None))
        
        # 特殊处理回环接口(lo)
        if 'lo' in interface_names and not any(name == 'lo' and ip for name, ip in interfaces):
            interfaces = [(name, ip) if name != 'lo' else ('lo', '127.0.0.1') for name, ip in interfaces]
        
        # 如果没有找到任何接口，抛出异常
        if not interfaces:
            raise RuntimeError("无法获取网络接口列表")
            
        return interfaces
        
    except Exception as e:
        # 捕获并记录异常，然后尝试备用方法
        print(f"获取网络接口信息时出错: {e}")
        
        # 备选方法: 使用ip命令的详细输出
        try:
            output = os.popen('ip -o -4 addr show').read()
            for line in output.splitlines():
                match = re.search(r'^\d+:\s+(\S+):\s+inet\s+(\S+)/', line)
                if match:
                    iface_name = match.group(1)
                    ip_address = match.group(2)
                    interfaces.append((iface_name, ip_address))
            
            # 添加没有IP的接口
            if os.path.exists('/sys/class/net'):
                all_ifaces = os.listdir('/sys/class/net')
                existing_ifaces = [name for name, _ in interfaces]
                for iface in all_ifaces:
                    if iface not in existing_ifaces:
                        interfaces.append((iface, None))
            
            if interfaces:
                return interfaces
        
        except Exception as e2:
            print(f"备用方法也出错: {e2}")
        
        # 如果所有方法都失败，抛出异常
        raise RuntimeError("无法获取网络接口列表")

def camera_transfer(param_dict):
    script_path = os.path.join(script_dir, "SET-UP-PORT-FORWARD-FOR-STREAMING.sh")
    logger.info("camera_transfer script_path {}".format(script_path))
    command = ['bash', script_path, param_dict['ni']]
    logger.info("camera_transfer command {}".format(command))
    print(command)
    try:
        result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, check=True)
        print('Return code:', result.returncode)
        logger.info('Return code: %s', result.returncode)
        if result.returncode == 0:
            print('Output:', result.stdout)
            logger.info('Return message: %s', result.stdout)
            return {'code': 200, 'message': "相机转流成功", 'data': {}}
        else:
            logger.info('Return message: %s', result.stderr)
            return {'code': REQUEST_FAIL, 'message': result.stderr, 'data': {}}
    except subprocess.CalledProcessError as e:
        # 如果命令执行失败，捕获异常并返回错误信息
        print(f"Error executing script: {e.stderr}")
        logger.error(f"Error executing script: {e.stderr}")
        return {'code': 500, 'message': e.stderr}

def build_rtsp_url(camera_ip, camera_user, camera_password, camera_port, camera_path):
    return f"rtsp://{camera_user}:{camera_password}@{camera_ip}:{camera_port}/h264/{camera_path}/main/av_stream"

def build_basic_info_with_rtsp_url(basic_info):
    basic_info['visibleRtspUrl'] = build_rtsp_url(basic_info['cameraIp'], basic_info['cameraUser'], basic_info['cameraPassword'], "554", "ch1")
    basic_info['infraredRtspUrl'] = build_rtsp_url(basic_info['cameraIp'], basic_info['cameraUser'], basic_info['cameraPassword'], "554", "ch2")
    server_ip = get_project_info()['serverIp']
    basic_info['serverIp'] = server_ip
    return basic_info

# endregion 相机转流

# region 设备管理调用接口
def update_ip_in_file(file_path, key, value):
    # 确保文件存在，如果不存在则创建
    if not os.path.exists(file_path):
        with open(file_path, 'w') as f:
            pass
    
    # 检查文件中是否存在给定的key
    key_exists = False
    with open(file_path, 'r') as f:
        for line in f:
            if line.startswith(key + '='):
                key_exists = True
                break
    
    try:
        if key_exists:
            # 如果存在，使用sed命令更新该行
            sed_command = f"sed -i 's/^{key}=.*$/{key}={value}/' {file_path}"
            subprocess.run(sed_command, shell=True, check=True)
        else:
            # 如果不存在，追加新的键值对
            with open(file_path, 'a') as f:
                f.write(f"{key}={value}\n")
    except subprocess.CalledProcessError as e:
        print(f"Error updating {key}: {e}")
        logger.error(f"Error updating {key}: {e}")
        raise Exception(f"更新文件: {file_path}的key: {key}失败, 错误信息: {e}")
    
def edit_project_ip_env(param_dict):
    project_info = get_project_info()
    project_name = project_info.get("fullProjectName")
    project_path = None
    for dir in os.listdir(project_dir):
        if os.path.isdir(os.path.join(project_dir, dir)) and dir == project_name:
            project_path = os.path.join(project_dir, project_name)
    if not project_path:
         logger.error(f"{project_name} 不存在")
         raise Exception(f"{project_name} 不存在")
    
    ip_env_path = os.path.join(project_dir , project_path, 'config', "ip.env")
    server_type = param_dict["server_type"]
    server_ip = param_dict["server_ip"]
    if server_type == 'DW':
        logger.info(f"修改DW ip为: {server_ip}")
        update_ip_in_file(ip_env_path, "DW_SERVER_IP", server_ip)
    elif server_type == 'PI':
        logger.info(f"修改PI ip为: {server_ip}")
        update_ip_in_file(ip_env_path, "PI_SERVER_IP", server_ip)
    else:
        raise Exception(f"未知Server类型: {server_type}")

# endregion 设备管理调用接口

# region 出厂能力配置（功能激活）
# 直接调用脚本的工具函数
def run_software_cipher(mode, input_path, output_path):
    """
    直接调用 software_cipher.py 命令行工具
    :param mode: obfuscate / deobfuscate
    :param input_path: 输入文件路径
    :param output_path: 输出文件路径
    :return: 执行结果，成功返回 True，失败返回 False
    """
    try:
        # 构建命令
        cmd = [
            "python3",
            SOFTWARE_CIPHER_PATH,
            "--mode", mode,
            "--input", input_path,
            "--output", output_path
        ]
        
        # 执行命令
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            encoding='utf-8',
            check=True  # 非0返回码抛出异常
        )
        
        logger.debug(f"执行 {mode} 成功: {result.stdout}")
        return True
        
    except subprocess.CalledProcessError as e:
        logger.error(f"执行 {mode} 失败: {e.stderr}")
        return False
    except Exception as e:
        logger.error(f"执行 {mode} 异常: {str(e)}")
        return False

def obfuscate_config_file(yaml_path, output_obf_path):
    """直接混淆配置文件"""
    if run_software_cipher("obfuscate", yaml_path, output_obf_path):
        logger.info(f"已生成混淆配置: {output_obf_path}")
        return True
    else:
        raise RuntimeError(f"混淆配置失败：{yaml_path} → {output_obf_path}")

def deobfuscate_config_file(obf_path):
    """直接解析混淆配置文件并返回配置字典"""
    try:
        # 创建临时文件存储解析后的明文
        temp_yaml = "temp_deobfuscated_config.yaml"
        
        # 调用脚本解析到临时文件
        if not run_software_cipher("deobfuscate", obf_path, temp_yaml):
            raise RuntimeError("解析混淆配置失败")
        
        # 读取临时文件内容并解析 YAML
        with open(temp_yaml, 'r', encoding='utf-8') as f:
            decrypted_content = f.read()
        config_dict = yaml.safe_load(decrypted_content)
        
        # 删除临时文件
        if os.path.exists(temp_yaml):
            os.remove(temp_yaml)
        
        return config_dict, True
    
    except Exception as e:
        # 清理临时文件
        temp_yaml = "temp_deobfuscated_config.yaml"
        if os.path.exists(temp_yaml):
            os.remove(temp_yaml)
        
        logger.error(f"配置解析失败: {str(e)}")
        return None, False

# 核心业务方法
def get_factory_set():
    """
    读取混淆配置，提取 capabilities 列表，整理为 JSON 返回
    返回格式：{"capabilities": [{"name": "xxx", "enabled": true}, ...]}
    """
    # 确保配置目录存在
    os.makedirs(os.path.dirname(ENCRYPTED_CONFIG_PATH), exist_ok=True)
    
    # 开发环境：如果没有混淆配置，从明文 YAML 生成（首次运行）
    if not os.path.exists(ENCRYPTED_CONFIG_PATH) and not os.path.exists(PLAINTEXT_CONFIG_PATH):
        logger.error("配置文件缺失：既没有混淆文件也没有明文文件")
        return {
            "status": "error",
            "message": "配置文件缺失，请联系管理员",
            "capabilities": []
        }, 500
    elif not os.path.exists(ENCRYPTED_CONFIG_PATH) and os.path.exists(PLAINTEXT_CONFIG_PATH):
        try:
            # 直接调用混淆函数
            obfuscate_config_file(PLAINTEXT_CONFIG_PATH, ENCRYPTED_CONFIG_PATH)
            
            # 删除明文文件
            try:
                os.remove(PLAINTEXT_CONFIG_PATH)
                logger.info(f"成功删除明文配置文件: {PLAINTEXT_CONFIG_PATH}")
            except Exception as e:
                logger.warning(f"删除明文配置文件失败: {str(e)}")
                
        except Exception as e:
            logger.error(f"生成混淆配置失败: {str(e)}")
            return {
                "status": "error",
                "message": f"生成混淆配置失败: {str(e)}",
                "capabilities": []
            }, 500
    
    try:
        # 1. 直接调用解析函数
        config_dict, is_valid = deobfuscate_config_file(ENCRYPTED_CONFIG_PATH)
        if not is_valid or not config_dict:
            logger.error("配置文件无效或解析失败")
            return {
                "status": "error",
                "message": "配置文件验证失败",
                "capabilities": []
            }, 500
        
        # 2. 提取 capabilities 列表（适配新 YAML 结构：capabilities 是列表）
        capabilities = config_dict.get("capabilities", [])
        if not isinstance(capabilities, list):
            logger.error("配置中 capabilities 格式错误（应为列表）")
            return {
                "status": "error",
                "message": "配置格式错误",
                "capabilities": []
            }, 500
        
        # 3. 整理为前台需要的格式
        formatted_capabilities = []
        for cap in capabilities:
            if "name" not in cap or "enabled" not in cap:
                logger.warning(f"跳过无效能力条目: {cap}")
                continue
            formatted_capabilities.append({
                "name": cap["name"],
                "zhName": cap.get("zh_name", ""),
                "enabled": cap["enabled"]
            })
        
        # 4. 返回成功响应
        logger.info(f"成功读取 {len(formatted_capabilities)} 个能力配置")
        return {
            "status": "success",
            "message": "配置读取成功",
            "capabilities": formatted_capabilities
        }, 200
    
    except Exception as e:
        logger.error(f"读取配置失败: {str(e)}", exc_info=True)
        return {
            "status": "error",
            "message": f"配置读取失败: {str(e)}",
            "capabilities": []
        }, 500

def update_factory_set(snake_param):
    """
    （扩展功能）更新 capabilities 中指定条目的 enabled 状态
    入参格式（snake_case）：{"capabilities": [{"name": "xxx", "enabled": false}, ...]}
    """
    try:
        # 1. 直接解析混淆配置
        config_dict, is_valid = deobfuscate_config_file(ENCRYPTED_CONFIG_PATH)
        if not is_valid or not config_dict:
            logger.error("配置文件无效，无法更新")
            return {"status": "error", "message": "配置文件验证失败"}, 500
        
        # 2. 提取待更新的能力列表
        update_list = snake_param.get("capabilities", [])
        if not isinstance(update_list, list):
            return {"status": "error", "message": "参数格式错误（capabilities 应为列表）"}, 400
        
        # 3. 遍历更新 enabled 状态
        capabilities = config_dict["capabilities"]
        updated_count = 0
        for update_item in update_list:
            cap_name = update_item.get("name")
            cap_enabled = update_item.get("enabled")
            if not cap_name or cap_enabled is None:
                logger.warning(f"跳过无效更新条目: {update_item}")
                continue
            
            for cap in capabilities:
                if cap.get("name") == cap_name:
                    cap["enabled"] = cap_enabled
                    updated_count += 1
                    logger.info(f"更新能力: {cap_name} → enabled={cap_enabled}")
                    break
        
        # 4. 重新混淆并保存配置
        if updated_count > 0:
            # 临时保存明文 YAML
            temp_yaml_path = "temp_update_config.yaml"
            with open(temp_yaml_path, 'w', encoding='utf-8') as f:
                yaml.dump(config_dict, f, sort_keys=False, default_flow_style=False)
            
            # 直接调用混淆函数
            obfuscate_config_file(temp_yaml_path, ENCRYPTED_CONFIG_PATH)
            
            # 删除临时文件
            if os.path.exists(temp_yaml_path):
                os.remove(temp_yaml_path)
        
        # 5. 返回更新结果
        return {
            "status": "success",
            "message": f"成功更新 {updated_count} 个能力配置",
            "updated_count": updated_count
        }, 200
    
    except Exception as e:
        logger.error(f"更新配置失败: {str(e)}", exc_info=True)
        return {"status": "error", "message": f"配置更新失败: {str(e)}"}, 500

# 获取格式化能力配置
def get_factory_set_formatted_capabilities():
    """
    读取混淆配置，提取 capabilities 列表，整理为 JSON 返回
    返回格式：{"capabilities": [{"name": "xxx", "related": {"docker_containers": "hardware"}}, ...]}
    """
    # 检查配置文件
    if not os.path.exists(ENCRYPTED_CONFIG_PATH) and not os.path.exists(PLAINTEXT_CONFIG_PATH):
        logger.error("配置文件缺失：既没有混淆文件也没有明文文件")
        return {
            "status": "error",
            "message": "配置文件缺失，既没有加密文件也没有明文文件，请联系管理员"
        }, 500
    elif not os.path.exists(ENCRYPTED_CONFIG_PATH) and os.path.exists(PLAINTEXT_CONFIG_PATH):
        logger.error("配置文件缺失：没有混淆文件，但有明文文件")
        return {
            "status": "error",
            "message": "配置文件缺失，没有混淆文件，请使用管理员账户登录后重新生成混淆配置"
        }, 500
    
    try:
        # 直接解析配置文件
        config_dict, is_valid = deobfuscate_config_file(ENCRYPTED_CONFIG_PATH)
        if not is_valid or not config_dict:
            logger.error("配置文件无效或解析失败")
            return []
        
        # 提取并格式化能力列表
        capabilities = config_dict.get("capabilities", [])
        if not isinstance(capabilities, list):
            logger.error("配置中 capabilities 格式错误（应为列表）")
            return []
        
        capabilities_data = {}
        formatted_capabilities = []
        for cap in capabilities:
            if "name" not in cap or "enabled" not in cap or cap["enabled"] is False:
                logger.warning(f"跳过无效能力条目: {cap}")
                continue
            
            cap_info = {
                "name": cap["name"],
                "zhName": cap.get("zh_name", ""),
                "enabled": False
            }
            formatted_capabilities.append(cap_info)

        capabilities_data["capabilities"] = formatted_capabilities
        logger.info(f"成功读取 {len(formatted_capabilities)} 个能力配置")
        return capabilities_data

    except Exception as e:
        logger.error(f"读取配置失败: {str(e)}", exc_info=True)
        return []

# 获取项目可选功能列表
def get_project_capabilities(project_full_name):
    project_path = os.path.join(project_dir, project_full_name)
    os.makedirs(project_path, exist_ok=True)
    project_capabilities_file_path = os.path.join(project_path, '.capabilities')
    
    if not os.path.exists(project_capabilities_file_path):
        # 创建隐藏文件并写入初始内容
        capabilities_data = get_factory_set_formatted_capabilities()
        with open(project_capabilities_file_path, 'w', encoding='utf-8') as f:
            json.dump(capabilities_data, f, indent=2, ensure_ascii=False)

    with open(project_capabilities_file_path, 'r', encoding='utf-8') as f:
        return json.load(f)

# endregion 获取项目可选功能列表

# region 配置中心-三方应用管理

# 定义路径常量
METADATA_BASE_PATH = "/opt/sysb/metadata/third_party_app"
MODULES_BASE_PATH = "/opt/sysb/modules/third_party_app"
PULL_SYSB_SCRIPT_PATH = "/opt/sysb/scripts/pull_from_cloud.py"
EXTRACT_MODULES_PATH = "/opt/sysb/scripts/extract_modules.py"
PROGRESS_FILE = "/opt/sandbox/rms_pull_progress.json"  # 拉取脚本进度文件
LATEST_DIR_NAME = "latest"
INSTALL_SCRIPT_NAME = "install.sh"

def check_latest_symlink(latest_path: str) -> bool:
    """
    检查latest目录是否为软链接
    :param latest_path: latest目录路径
    :return: 是否为软链接
    """
    try:
        return os.path.islink(latest_path) and os.path.isdir(latest_path)
    except Exception as e:
        logger.error(f"检查软链接失败 {latest_path}: {str(e)}")
        return False

def execute_status_script(script_path: str) -> int:
    """
    执行status.sh脚本获取运行状态
    :param script_path: 脚本路径
    :return: 1=脚本执行成功且输出为"1"（运行中），0=其他所有情况（未运行/脚本不存在/执行失败/超时/输出非1）
    """
    try:
        # 检查脚本是否存在且可执行
        if not os.path.exists(script_path):
            logger.warning(f"状态脚本不存在: {script_path}")
            return 2
        if not os.access(script_path, os.X_OK):
            try:
                os.chmod(script_path, 0o755)
                logger.info(f"已赋予状态脚本执行权限: {script_path}")
            except Exception as e:
                logger.error(f"赋予状态脚本执行权限失败 {script_path}: {str(e)}")
                return 2

        # 执行脚本，超时时间30秒
        result = subprocess.run(
            [script_path],
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,  # 合并stderr到stdout，方便排查问题
            text=True,
            timeout=30,
            cwd=os.path.dirname(script_path)
        )

        # 先判断脚本执行是否成功（返回码0）
        if result.returncode != 0:
            logger.warning(
                f"状态脚本执行失败（返回码: {result.returncode}），输出: {result.stdout.strip()}"
            )
            return 2

        # 再判断输出是否为"1"（注意是字符串比较）
        script_output = result.stdout.strip()
        if script_output == "1":
            return 3
        else:
            logger.info(f"状态脚本输出非1: {script_output}（脚本路径: {script_path}）")
            return 2

    except subprocess.TimeoutExpired:
        logger.error(f"状态脚本执行超时（30秒）: {script_path}")
        return 2
    except Exception as e:
        logger.error(f"执行状态脚本异常 {script_path}: {str(e)}", exc_info=True)
        return 2

def get_app_list() -> List[Dict]:
    """获取所有第三方应用状态列表"""
    apps = []

    if not os.path.exists(METADATA_BASE_PATH):
        logger.warning(f"元数据目录不存在: {METADATA_BASE_PATH}")
        return apps

    for app_name in os.listdir(METADATA_BASE_PATH):
        app_metadata_path = os.path.join(METADATA_BASE_PATH, app_name)

        # 只处理目录且包含manifest.yaml
        if not os.path.isdir(app_metadata_path):
            continue

        manifest_file = os.path.join(app_metadata_path, "manifest.yaml")
        if not os.path.exists(manifest_file):
            logger.warning(f"应用 {app_name} 缺少manifest.yaml文件")
            continue

        # 初始化应用状态
        app_status = {
            "appName": app_name,
            "status": 1,
            "cn_description": None,
            "en_description": None,
            "error_msg": None
        }

        # 检查模块目录和latest软链接
        app_module_path = os.path.join(MODULES_BASE_PATH, app_name)
        latest_path = os.path.join(app_module_path, LATEST_DIR_NAME)

        if os.path.exists(app_module_path):
            if check_latest_symlink(latest_path):
                app_status["status"] = 2
                # 执行status.sh
                status_script = os.path.join(latest_path, "status.sh")
                app_status["status"] = execute_status_script(status_script)
        else:
            logger.error("模块目录不存在")
            app_status["error_msg"] = "模块目录不存在"

        apps.append(app_status)

    return apps


def execute_operation_script(app_name: str, script_name: str) -> Dict[str, Union[bool, str]]:
    """执行启动/停止/卸载脚本，返回执行结果"""
    # 验证应用是否存在
    app_metadata_path = os.path.join(METADATA_BASE_PATH, app_name)
    if not os.path.isdir(app_metadata_path):
        return {
            "success": False,
            "message": f"应用 {app_name} 不存在(元数据目录缺失)",
            "output": ""
        }

    # 验证应用是否已安装
    app_module_path = os.path.join(MODULES_BASE_PATH, app_name)
    latest_path = os.path.join(app_module_path, LATEST_DIR_NAME)
    if not check_latest_symlink(latest_path):
        return {
            "success": False,
            "message": f"应用 {app_name} 未安装(latest目录不是有效软链接)",
            "output": ""
        }

    # 验证脚本是否存在
    script_path = os.path.join(latest_path, script_name)
    if not os.path.exists(script_path):
        return {
            "success": False,
            "message": f"脚本 {script_name} 不存在",
            "output": ""
        }

    # 验证脚本是否可执行
    if not os.access(script_path, os.X_OK):
        try:
            os.chmod(script_path, 0o755)
            logger.info(f"已赋予脚本 {script_name} 执行权限")
        except Exception as e:
            logger.error(f"赋予脚本 {script_name} 执行权限失败: {str(e)}")
            return {
                "success": False,
                "message": f"{script_name} 执行权限失败: {str(e)}",
                "output": ""
            }

    # 执行脚本
    try:
        logger.info(f"开始执行 {app_name} 的 {script_name} 脚本")
        result = subprocess.run(
            [script_path],
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            timeout=300,  # 5分钟超时
            cwd=os.path.dirname(script_path)
        )
        output = result.stdout.strip()

        if result.returncode == 0:
            logger.info(f"{app_name} 的 {script_name} 脚本执行成功")
            return {
                "success": True,
                "message": f"{script_name} 执行成功",
                "output": output
            }
        else:
            logger.error(f"{app_name} 的 {script_name} 脚本执行失败，返回码: {result.returncode}")
            return {
                "success": False,
                "message": f"{script_name} 执行失败(返回码: {result.returncode})",
                "output": output
            }
    except subprocess.TimeoutExpired:
        logger.error(f"{app_name} 的 {script_name} 脚本执行超时")
        return {
            "success": False,
            "message": f"{script_name} 执行超时(超过5分钟)",
            "output": ""
        }
    except Exception as e:
        logger.error(f"{app_name} 的 {script_name} 脚本执行异常: {str(e)}")
        return {
            "success": False,
            "message": f"{script_name} 执行异常: {str(e)}",
            "output": ""
        }

# 任务状态枚举
TASK_STATUS = {
    "PULLING": "pulling",    # 拉取中
    "INSTALLING": "installing",  # 安装中
    "COMPLETED": "completed",    # 完成
    "FAILED": "failed"       # 失败
}

TASK_STORAGE = "/opt/sandbox/install_tasks.json"
# 确保目录存在
os.makedirs("/opt/sandbox", exist_ok=True)

# ===================== 任务管理工具函数 =====================
def load_tasks() -> Dict[str, Dict]:
    """加载所有任务"""
    try:
        if os.path.exists(TASK_STORAGE):
            logger.info(f"从 {TASK_STORAGE} 加载任务")
            with open(TASK_STORAGE, "r", encoding="utf-8") as f:
                tasks = json.load(f)
                logger.info(f"成功加载 {len(tasks)} 个任务")
                return tasks
    except Exception as e:
        logger.error(f"加载任务失败: {e}", exc_info=True)
    return {}

def save_tasks(tasks: Dict[str, Dict]) -> None:
    """保存任务 - 完全覆盖写入，确保JSON格式正确"""
    try:
        logger.debug(f"保存 {len(tasks)} 个任务到 {TASK_STORAGE}")

        # 使用临时文件写入，确保原子性
        temp_file = f"{TASK_STORAGE}.tmp"

        # 确保目录存在
        os.makedirs(os.path.dirname(TASK_STORAGE), exist_ok=True)

        # 写入临时文件
        with open(temp_file, "w", encoding="utf-8") as f:
            json.dump(tasks, f, ensure_ascii=False, indent=2)

        # 原子性地替换原文件
        os.replace(temp_file, TASK_STORAGE)

        logger.debug(f"任务保存成功，文件大小: {os.path.getsize(TASK_STORAGE)} bytes")

    except Exception as e:
        logger.error(f"保存任务失败: {e}", exc_info=True)

        # 如果失败，尝试直接写入（非原子方式）
        try:
            with open(TASK_STORAGE, "w", encoding="utf-8") as f:
                json.dump(tasks, f, ensure_ascii=False, indent=2)
            logger.warning(f"使用直接写入方式保存成功")
        except Exception as e2:
            logger.error(f"直接写入也失败: {e2}")

def create_task(app_name: str) -> str:
    """创建新任务"""
    task_id = f"install_{app_name}_{uuid.uuid4().hex[:8]}"
    logger.info(f"创建新任务: task_id={task_id}, app_name={app_name}")

    tasks = load_tasks()
    tasks[task_id] = {
        "task_id": task_id,
        "app_name": app_name,
        "status": TASK_STATUS["PULLING"],
        "progress": 0.0,
        "step_progress": 0.0,
        "step": "pulling",
        "message": "任务已初始化",
        "create_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "update_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "pull_log": "",
        "install_log": "",
        "error": ""
    }
    save_tasks(tasks)
    logger.info(f"任务创建完成: {task_id}")
    return task_id

def update_task(
        task_id: str,
        status: str = None,
        progress: float = None,
        step_progress: float = None,
        step: str = "",
        message: str = "",
        error: str = "",
        pull_log: str = "",
        install_log: str = ""
) -> None:
    """更新任务状态"""
    logger.debug(f"更新任务状态: task_id={task_id}, status={status}, progress={progress}, step={step}, message={message}")

    tasks = load_tasks()
    if task_id not in tasks:
        logger.warning(f"任务 {task_id} 不存在，无法更新")
        return

    task = tasks[task_id]
    if status is not None:
        logger.debug(f"任务 {task_id} 状态从 {task['status']} 更新为 {status}")
        task["status"] = status
    if progress is not None:
        logger.debug(f"任务 {task_id} 进度从 {task['progress']} 更新为 {progress}")
        task["progress"] = round(progress, 2)
    if step_progress is not None:
        logger.debug(f"任务 {task_id} 步骤进度从 {task['step_progress']} 更新为 {step_progress}")
        task["step_progress"] = round(step_progress, 2)
    if step:
        logger.debug(f"任务 {task_id} 步骤从 {task['step']} 更新为 {step}")
        task["step"] = step
    if message:
        logger.debug(f"任务 {task_id} 消息更新为: {message}")
        task["message"] = message
    if error:
        logger.error(f"任务 {task_id} 记录错误: {error}")
        task["error"] = error
    if pull_log:
        logger.debug(f"任务 {task_id} 添加拉取日志，长度: {len(pull_log)}")
        # 确保日志不会无限增长，限制最大长度
        current_log = task.get("pull_log", "")
        # 限制日志长度，防止文件过大（例如限制在10万字符内）
        max_log_length = 100000
        if len(current_log) + len(pull_log) > max_log_length:
            # 如果超过最大长度，截断旧日志
            current_log = current_log[-(max_log_length - len(pull_log)):]
        task["pull_log"] = current_log + pull_log
    if install_log:
        logger.debug(f"任务 {task_id} 添加安装日志，长度: {len(install_log)}")
        # 确保日志不会无限增长，限制最大长度
        current_log = task.get("install_log", "")
        # 限制日志长度，防止文件过大（例如限制在10万字符内）
        max_log_length = 100000
        if len(current_log) + len(install_log) > max_log_length:
            # 如果超过最大长度，截断旧日志
            current_log = current_log[-(max_log_length - len(install_log)):]
        task["install_log"] = current_log + install_log

    task["update_time"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

    save_tasks(tasks)
    logger.debug(f"任务 {task_id} 更新完成")

def get_task(task_id: str) -> Optional[Dict]:
    """获取任务信息"""
    logger.debug(f"获取任务信息: task_id={task_id}")
    tasks = load_tasks()
    if task_id in tasks:
        logger.debug(f"找到任务 {task_id}: status={tasks[task_id].get('status')}")
        return tasks[task_id]
    else:
        logger.warning(f"任务 {task_id} 不存在")
        return None

# ===================== 进度解析函数 =====================
def parse_pull_progress() -> Tuple[float, float, str, str]:
    """解析拉取进度文件"""
    try:
        if not os.path.exists(PROGRESS_FILE):
            logger.debug(f"进度文件不存在: {PROGRESS_FILE}")
            return 0.0, 0.0, "idle", "进度文件不存在"

        logger.debug(f"读取进度文件: {PROGRESS_FILE}")
        with open(PROGRESS_FILE, "r", encoding="utf-8") as f:
            data = json.load(f)
            logger.debug(f"进度文件内容: {json.dumps(data, ensure_ascii=False)}")

        # 进度百分比（组件内进度）
        component_pct = float(data.get("percentage", 0.0))
        # 整体进度百分比（所有组件）
        step_pct = float(data.get("step_percentage", 0.0))
        # 状态
        status = data.get("status", "unknown")
        # 附加信息
        message = data.get("extra", {}).get("message", "")
        if not message:
            message = data.get("status", "")

        # 获取模块信息
        module = data.get("module", "")
        if module:
            message = f"{module}: {message}"

        logger.debug(f"解析进度: component_pct={component_pct}, step_pct={step_pct}, status={status}, message={message}")
        return component_pct, step_pct, status, message
    except Exception as e:
        logger.error(f"解析拉取进度失败: {e}", exc_info=True)
        return 0.0, 0.0, "error", f"解析进度失败: {str(e)}"

def call_extract_modules(app_name: str) -> None:
    """调用 extract_modules.py 解压 host_package，返回执行结果"""
    # 1. 获取模块对应的manifest路径（与pull_from_cloud保持一致）
    app_metadata_path = os.path.join(METADATA_BASE_PATH, app_name)

    manifest_file = os.path.join(app_metadata_path, "manifest.yaml")
    if not os.path.exists(manifest_file):
        error_msg = f"模块manifest文件不存在：{manifest_file}"
        logger.error(error_msg)
        raise FileNotFoundError(error_msg)

    # 2. 检查解压脚本是否存在
    if not os.path.exists(EXTRACT_MODULES_PATH):
        error_msg = f"解压脚本不存在：{EXTRACT_MODULES_PATH}"
        logger.error(error_msg)
        raise FileNotFoundError(error_msg)

    # 3. 检查脚本执行权限
    if not os.access(EXTRACT_MODULES_PATH, os.X_OK):
        logger.warning(f"extract_modules.py 缺少执行权限，尝试添加")
        subprocess.run(
            ["chmod", "+x", EXTRACT_MODULES_PATH],
            check=True,
            capture_output=True,
            text=True
        )

    # 4. 执行解压脚本（类似pull_from_cloud的调用方式，实时输出日志）

    current_user = get_current_login_user()
    logger.info(f"current_user：{current_user}")
    logger.info(f"开始解压模块资源：{app_name}（manifest：{manifest_file}）")

    # 构建命令：使用 sudo -u 切换到指定用户执行
    if current_user and current_user != 'root':
        cmd = ["sudo", "-u", current_user, "python3", EXTRACT_MODULES_PATH, manifest_file]
    else:
        cmd = ["python3", EXTRACT_MODULES_PATH, manifest_file]

    logger.info(f"执行命令: {' '.join(cmd)}")

    try:
        process = subprocess.Popen(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            bufsize=1,
            universal_newlines=True
        )

        stdout_lines = []
        if process.stdout:
            for line in iter(process.stdout.readline, ''):
                line = line.strip()
                if line:
                    stdout_lines.append(line)
                    logger.info(f"extract_modules 输出：{line}")

        process.wait()
        returncode = process.returncode

        if returncode != 0:
            error_msg = f"模块{app_name}解压失败（返回码：{returncode}），详细日志：\n{chr(10).join(stdout_lines)}"
            logger.error(error_msg)
            raise RuntimeError(error_msg)

        logger.info(f"模块{app_name}解压成功")

    except subprocess.TimeoutExpired:
        error_msg = f"模块{app_name}解压超时（超过1小时）"
        logger.error(error_msg)
        raise TimeoutError(error_msg)
    except Exception as e:
        error_msg = f"解压模块{app_name}资源异常：{str(e)}"
        logger.error(error_msg)
        raise RuntimeError(error_msg)


def get_current_login_user():
    """
    获取最合适的用户名
    优先级：环境变量 USER > USERNAME > logname > whoami > 备用方案
    """
    # 1. 尝试环境变量 USER
    username = os.environ.get('USER')
    logger.info(f"尝试环境变量 USER:{username}")
    if username and username != 'root':
        return username

    # 2. 尝试环境变量 USERNAME（Windows兼容）
    username = os.environ.get('USERNAME')
    logger.info(f"尝试环境变量 USERNAME:{username}")
    if username and username != 'root':
        return username

    # 2. 尝试环境变量 USERNAME（Windows兼容）
    username = os.environ.get('SUDO_USER')
    logger.info(f"尝试环境变量 SUDO_USER:{username}")
    if username and username != 'root':
        return username

    # 3. 尝试 logname（登录时的原始用户）
    try:
        result = subprocess.run(
            ['logname'],
            capture_output=True,
            text=True,
            timeout=2,
            check=False
        )
        if result.returncode == 0:
            username = result.stdout.strip()
            logger.info(f"logname username:{username}")
            if username and username != 'root':
                return username
    except:
        pass

    # 4. 尝试 whoami（当前有效用户）
    try:
        result = subprocess.run(
            ['whoami'],
            capture_output=True,
            text=True,
            timeout=2,
            check=False
        )
        if result.returncode == 0:
            username = result.stdout.strip()
            logger.info(f"whoami username:{username}")
            if username and username != 'root':
                return username
    except:
        pass

    # 5. 备用方案：使用 pwd 获取家目录名
    try:
        # 获取家目录路径并提取用户名
        home_dir = os.path.expanduser('~')
        if home_dir and home_dir != '~':
            # 从路径中提取用户名（通常是最后一部分）
            username = os.path.basename(home_dir)
            logger.info(f"路径中提取用户名 username:{username}")
            if username and username != 'root':
                return username
    except:
        pass

    try:
        result = subprocess.run(
            ['loginctl', 'list-users'],
            capture_output=True,
            text=True,
            timeout=2
        )
        if result.returncode == 0:
            # 输出示例:
            #  UID USER
            # 1000 lenovo
            lines = result.stdout.strip().split('\n')[1:]
            for line in lines:
                parts = line.split()
                if len(parts) >= 2 and parts[0] != '0' and parts[1] != 'root':
                    logger.info(f"list-users username:{parts[1]}")
                    return parts[1]
    except:
        pass

    try:
        output = subprocess.check_output(
            ["loginctl", "list-sessions"],
            text=True
        )
        # 找带有 * 的会话（当前活动会话）
        match = re.search(r'\*.*?(\w+)\s+\(', output)
        if match and match.group(1) != 'root':
            return match.group(1)

        # 如果没有 *，就取第一个非 root 用户
        lines = output.strip().split('\n')[1:]
        for line in lines:
            parts = line.split()
            if len(parts) >= 3 and parts[2] != 'root':
                logger.info(f"list-sessions username:{parts[2]}")
                return parts[2]
    except:
        pass

    try:
        output = subprocess.check_output(["who"], text=True, timeout=5)
        for line in output.strip().split('\n'):
            user = line.split()[0]
            logger.info(f"who username:{user}")
            if user != 'root':
                return user
    except:
        pass

    return None

# ===================== 脚本执行函数 =====================
def execute_pull_script(app_name: str, task_id: str) -> None:
    """执行拉取脚本（异步）"""
    logger.info(f"开始执行拉取脚本: app_name={app_name}, task_id={task_id}")

    # 更新任务状态
    update_task(
        task_id=task_id,
        status=TASK_STATUS["PULLING"],
        progress=0.0,
        step_progress=0.0,
        step="pulling",
        message="开始拉取应用资源"
    )

    # 构造拉取命令 - 根据 pull_from_cloud.py 的用法，支持传入模块名
    # 对于第三方应用，正确的模块名格式是 "third_party_app/app_name"
    full_module_name = f"third_party_app/{app_name}"

    current_user = get_current_login_user()
    if current_user and current_user != 'root':
        cmd = ["sudo", "-u", current_user, "python3", PULL_SYSB_SCRIPT_PATH, full_module_name]
    else:
        cmd = ["python3", PULL_SYSB_SCRIPT_PATH, full_module_name]

    logger.info(f"执行拉取命令: {' '.join(cmd)}")
    pull_log = f"执行命令: {' '.join(cmd)}\n"
    update_task(task_id=task_id, status=TASK_STATUS["PULLING"], pull_log=pull_log)

    try:
        # 执行拉取脚本
        logger.info("启动拉取脚本进程")
        process = subprocess.Popen(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            bufsize=1,
            universal_newlines=True,
            preexec_fn=os.setsid
        )
        logger.info(f"拉取脚本进程启动成功，PID: {process.pid}")

        # 实时监控进度和输出
        last_progress_update = time.time()
        pull_lines = 0

        logger.info("开始监控拉取进度和输出")
        while process.poll() is None:
            current_time = time.time()

            # 解析进度文件（从 pull_from_cloud.py 写入）
            comp_pct, step_pct, status, msg = parse_pull_progress()

            # 定期更新进度（避免太频繁）
            if current_time - last_progress_update > 0.5:
                # 计算整体进度（拉取阶段占80%）
                total_progress = step_pct * 0.8

                logger.debug(f"更新拉取进度: step_pct={step_pct}, total_progress={total_progress}, message={msg}")

                # 更新任务状态
                update_task(
                    task_id=task_id,
                    status=TASK_STATUS["PULLING"],
                    progress=total_progress,
                    step_progress=step_pct,
                    step="pulling",
                    message=msg
                )
                last_progress_update = current_time

            # 读取脚本输出
            if process.stdout:
                try:
                    line = process.stdout.readline()
                    if line:
                        pull_lines += 1
                        # 过滤掉 pull_from_cloud.py 的进度输出（以回车符开头）
                        if not line.startswith('\r'):
                            pull_log += line
                            update_task(task_id=task_id, status=TASK_STATUS["PULLING"], pull_log=line)

                            # 记录关键信息
                            if "ERROR" in line or "error" in line.lower():
                                logger.error(f"拉取脚本错误输出: {line.strip()}")
                            elif "WARN" in line or "warning" in line.lower():
                                logger.warning(f"拉取脚本警告输出: {line.strip()}")
                        elif pull_lines % 10 == 0:  # 每10行进度输出记录一次
                            logger.debug(f"拉取进度输出: {line.strip()}")
                except Exception as read_error:
                    logger.warning(f"读取拉取脚本输出时出错: {read_error}")

            time.sleep(0.1)

        logger.info(f"拉取脚本进程已结束，读取剩余输出")
        # 读取剩余输出
        if process.stdout:
            try:
                remaining = process.stdout.read()
                if remaining:
                    pull_log += remaining
                    update_task(task_id=task_id, status=TASK_STATUS["PULLING"], pull_log=remaining)
                    logger.debug(f"读取到剩余输出，长度: {len(remaining)}")
            except Exception as read_error:
                logger.warning(f"读取剩余输出时出错: {read_error}")

        # 检查拉取结果
        return_code = process.poll()
        pull_log += f"拉取完成，返回码: {return_code}\n"
        logger.info(f"拉取脚本执行完成，返回码: {return_code}")

        if return_code != 0:
            error_msg = f"拉取脚本执行失败，返回码: {return_code}"
            logger.error(error_msg)
            raise RuntimeError(error_msg)

        update_task(
            task_id=task_id,
            status=TASK_STATUS["PULLING"],
            progress=80.0,
            step_progress=100.0,
            step="pulling",
            message="拉取完成，开始执行安装脚本",
            pull_log=pull_log
        )

        logger.info("拉取阶段完成，开始执行安装脚本")
        # 拉取完成，执行安装脚本
        time.sleep(1)  # 等待文件系统同步

        call_extract_modules(app_name)
        execute_install_script(app_name, task_id)

    except Exception as e:
        logger.error(f"拉取脚本执行失败: {e}", exc_info=True)
        update_task(
            task_id=task_id,
            status=TASK_STATUS["FAILED"],
            progress=0.0,
            step_progress=0.0,
            step="pulling",
            message=f"拉取失败: {str(e)}",
            error=str(e),
            pull_log=pull_log
        )



def read_manifest_version(app_name: str) -> str:
    """从 manifest.yaml 读取版本号"""
    manifest_path = os.path.join(METADATA_BASE_PATH, app_name, "manifest.yaml")
    logger.info(f"读取manifest文件: {manifest_path}")

    if not os.path.exists(manifest_path):
        error_msg = f"manifest文件不存在: {manifest_path}"
        logger.error(error_msg)
        raise FileNotFoundError(error_msg)

    try:
        with open(manifest_path, "r", encoding="utf-8") as f:
            manifest_data = yaml.safe_load(f)

        version = manifest_data.get("version")
        if not version:
            error_msg = f"manifest文件中未找到version字段: {manifest_path}"
            logger.error(error_msg)
            raise ValueError(error_msg)

        logger.info(f"从manifest读取到版本号: {version}")
        return version
    except yaml.YAMLError as e:
        error_msg = f"解析manifest文件失败: {manifest_path}, 错误: {e}"
        logger.error(error_msg)
        raise ValueError(error_msg)
    except Exception as e:
        error_msg = f"读取manifest文件失败: {manifest_path}, 错误: {e}"
        logger.error(error_msg)
        raise

def find_install_script_path(app_name: str, version: str) -> Optional[str]:
    """
    查找install.sh脚本路径
    路径结构: /opt/sysb/modules/third_party_app/{app_name}/{version}/{唯一目录名}/install.sh
    """
    # 基础路径
    base_dir = os.path.join(MODULES_BASE_PATH, app_name, version)
    logger.info(f"查找安装脚本，基础目录: {base_dir}")

    if not os.path.exists(base_dir):
        logger.error(f"版本目录不存在: {base_dir}")
        return None

    # 列出目录内容
    try:
        items = os.listdir(base_dir)
        logger.info(f"版本目录内容: {items}")

        # 查找子目录
        subdirs = []
        for item in items:
            item_path = os.path.join(base_dir, item)
            if os.path.isdir(item_path):
                subdirs.append(item)
                logger.debug(f"找到子目录: {item}")

        if not subdirs:
            logger.error(f"版本目录下没有子目录: {base_dir}")
            return None

        # 如果有多个子目录，选择第一个（通常只有一个）
        if len(subdirs) > 1:
            logger.warning(f"版本目录下有多个子目录: {subdirs}，将使用第一个: {subdirs[0]}")

        subdir = subdirs[0]
        subdir_path = os.path.join(base_dir, subdir)

        # 查找install.sh
        install_script_path = os.path.join(subdir_path, INSTALL_SCRIPT_NAME)

        if os.path.exists(install_script_path):
            logger.info(f"找到安装脚本: {install_script_path}")
            return install_script_path
        else:
            # 列出子目录内容
            subdir_contents = os.listdir(subdir_path)
            logger.error(f"子目录 {subdir_path} 中未找到 {INSTALL_SCRIPT_NAME}，目录内容: {subdir_contents}")

            # 尝试在子目录中递归查找
            for root, dirs, files in os.walk(subdir_path):
                if INSTALL_SCRIPT_NAME in files:
                    found_path = os.path.join(root, INSTALL_SCRIPT_NAME)
                    logger.info(f"在子目录中递归查找到安装脚本: {found_path}")
                    return found_path

            return None

    except Exception as e:
        logger.error(f"查找安装脚本时出错: {e}", exc_info=True)
        return None

def execute_install_script(app_name: str, task_id: str) -> None:
    """执行安装脚本 - 只检查返回码"""
    logger.info(f"开始执行安装脚本: app_name={app_name}, task_id={task_id}")

    # 更新任务状态
    update_task(
        task_id=task_id,
        status=TASK_STATUS["INSTALLING"],
        progress=80.0,
        step_progress=0.0,
        step="installing",
        message="开始执行安装脚本"
    )

    # 1. 从manifest.yaml读取版本号
    logger.info(f"读取应用 {app_name} 的版本号")
    version = read_manifest_version(app_name)

    # 2. 构造安装脚本路径
    # 格式: /opt/sysb/modules/third_party_app/{app_name}/{version}/{app_name}/install.sh
    install_script_path = find_install_script_path(app_name, version)
    # install_script_path = os.path.join(MODULES_BASE_PATH, app_name, version, app_name, INSTALL_SCRIPT_NAME)

    logger.info(f"安装脚本路径: {install_script_path}")

    # 检查安装脚本是否存在 - 如果不存在，标记为失败
    if not os.path.exists(install_script_path):
        error_msg = f"安装脚本不存在: {install_script_path}"
        logger.error(error_msg)
        # 检查 latest 目录是否存在
        latest_dir = os.path.join(MODULES_BASE_PATH, app_name, "latest")
        if not os.path.exists(latest_dir):
            logger.error(f"latest目录不存在: {latest_dir}")
        else:
            logger.info(f"latest目录存在，内容: {os.listdir(latest_dir)}")

        install_log = f"错误: {error_msg}\n"
        update_task(
            task_id=task_id,
            status=TASK_STATUS["FAILED"],
            progress=80.0,
            step_progress=0.0,
            step="installing",
            message=error_msg,
            error=error_msg,
            install_log=install_log
        )
        return

    logger.info(f"安装脚本存在，文件大小: {os.path.getsize(install_script_path)} bytes")

    # =====================dwdeploye 修改.env文件中的CUR_IP =====================


    if app_name == "dwdeploy_lite":
        try:
            env_file = os.path.join(os.path.dirname(install_script_path), ".env")

            if os.path.exists(env_file):
                robot_ip = get_robot_ip()
                if robot_ip:
                    logger.info(f"更新.env文件中的CUR_IP为: {robot_ip}")

                    # 读取文件所有行
                    with open(env_file, 'r', encoding='utf-8') as f:
                        lines = f.readlines()

                    # 查找并修改CUR_IP行
                    for i, line in enumerate(lines):
                        stripped = line.strip()
                        if stripped.startswith('CUR_IP='):
                            # 找到CUR_IP行，直接替换整行
                            old_ip = stripped.split('=')[1]
                            logger.info(f"将CUR_IP从 {old_ip} 改为 {robot_ip}")
                            lines[i] = f"CUR_IP={robot_ip}\n"
                            break

                    # 写回文件
                    with open(env_file, 'w', encoding='utf-8') as f:
                        f.writelines(lines)

        except Exception as e:
            logger.warning(f"修改.env文件失败: {e}")
        # ===================== 修改.env文件结束 =====================

    # 添加执行权限
    try:
        current_mode = os.stat(install_script_path).st_mode
        logger.debug(f"安装脚本当前权限: {oct(current_mode)}")
        os.chmod(install_script_path, 0o755)
        new_mode = os.stat(install_script_path).st_mode
        logger.info(f"安装脚本权限已修改为: {oct(new_mode)}")
    except Exception as e:
        logger.warning(f"无法更改安装脚本权限: {e}")

    # 执行安装脚本
    install_log = f"执行安装脚本: {install_script_path}\n工作目录: {os.path.dirname(install_script_path)}\n"
    logger.info(f"开始执行安装脚本，工作目录: {os.path.dirname(install_script_path)}")

    update_task(
        task_id=task_id,
        status=TASK_STATUS["INSTALLING"],
        progress=80.0,
        step_progress=0.0,
        step="installing",
        message="正在执行安装脚本",
        install_log=install_log
    )

    try:
        # 切换到 latest 目录执行安装脚本
        install_dir = os.path.dirname(install_script_path)
        logger.info(f"切换到工作目录: {install_dir}")

        # 检查目录内容
        if os.path.exists(install_dir):
            dir_contents = os.listdir(install_dir)
            logger.info(f"工作目录内容: {dir_contents}")

        process = subprocess.Popen(
            [install_script_path],
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            bufsize=1,
            universal_newlines=True,
            cwd=install_dir  # 在 latest 目录中执行
        )
        logger.info(f"安装脚本进程启动成功，PID: {process.pid}")

        # 安装阶段进度计算
        install_start_time = time.time()
        install_duration = 300  # 5分钟 = 300秒

        # 实时监控安装输出和进度
        install_finished = False
        install_lines = 0

        logger.info("开始监控安装脚本执行")
        while not install_finished:
            current_time = time.time()
            elapsed_time = current_time - install_start_time

            # 读取输出
            if process.stdout:
                try:
                    line = process.stdout.readline()
                    if line:
                        install_lines += 1
                        install_log += line
                        update_task(task_id=task_id, status=TASK_STATUS["INSTALLING"], install_log=line)

                        # 记录关键信息
                        if "ERROR" in line or "error" in line.lower():
                            logger.error(f"安装脚本错误输出: {line.strip()}")
                        elif "WARN" in line or "warning" in line.lower():
                            logger.warning(f"安装脚本警告输出: {line.strip()}")
                        elif install_lines % 10 == 0:  # 每10行输出记录一次
                            logger.info(f"安装脚本输出(第{install_lines}行): {line.strip()}")
                except Exception as read_error:
                    logger.warning(f"读取安装脚本输出时出错: {read_error}")

            # 检查进程是否已结束
            return_code = process.poll()
            if return_code is not None:
                install_finished = True
                logger.info(f"安装脚本进程已结束，返回码: {return_code}")

                # 读取剩余输出
                if process.stdout:
                    try:
                        remaining = process.stdout.read()
                        if remaining:
                            install_log += remaining
                            update_task(task_id=task_id, status=TASK_STATUS["INSTALLING"], install_log=remaining)
                            logger.debug(f"读取到安装脚本剩余输出，长度: {len(remaining)}")
                    except Exception as read_error:
                        logger.warning(f"读取安装脚本剩余输出时出错: {read_error}")

                # 检查安装结果
                install_log += f"安装脚本执行完成，返回码: {return_code}\n"
                logger.info(f"安装脚本执行完成，总输出行数: {install_lines}，返回码: {return_code}")

                if return_code == 0:
                    # 安装成功，立即跳到100%
                    logger.info(f"安装成功，任务完成: {task_id}")
                    update_task(
                        task_id=task_id,
                        status=TASK_STATUS["COMPLETED"],
                        progress=100.0,
                        step_progress=100.0,
                        step="completed",
                        message="安装成功完成",
                        install_log=install_log
                    )
                else:
                    # 安装失败
                    error_msg = f"安装脚本执行失败，返回码: {return_code}"
                    logger.error(error_msg)
                    raise RuntimeError(error_msg)
                break

            # 计算进度（5分钟内从0%增加到95%，然后保持95%）
            if elapsed_time < install_duration:
                # 在5分钟内逐渐增加到95%
                progress_ratio = elapsed_time / install_duration
                install_progress = min(95.0, progress_ratio * 100.0)  # 0-95%
                logger.debug(f"安装进度计算: elapsed_time={elapsed_time:.1f}s, progress_ratio={progress_ratio:.3f}, install_progress={install_progress:.1f}%")
            else:
                # 5分钟后保持95%
                install_progress = 95.0
                if int(elapsed_time) % 30 == 0:  # 每30秒记录一次
                    logger.info(f"安装脚本已运行 {int(elapsed_time)}秒，进度保持在95%")

            # 计算整体进度：80% + (安装进度 * 19%)，达到99%
            total_progress = 80.0 + (install_progress / 100.0) * 19.0

            # 更新任务状态
            if int(elapsed_time) % 10 == 0:  # 每10秒记录一次进度更新
                logger.info(f"更新安装进度: total_progress={total_progress:.1f}%, install_progress={install_progress:.1f}%, elapsed_time={int(elapsed_time)}s")

            update_task(
                task_id=task_id,
                status=TASK_STATUS["INSTALLING"],
                progress=total_progress,
                step_progress=install_progress,
                step="installing",
                message=f"安装脚本执行中（已运行 {int(elapsed_time)}秒）..."
            )

            time.sleep(0.5)  # 500ms更新一次

    except Exception as e:
        logger.error(f"安装脚本执行失败: {e}", exc_info=True)
        # 计算当前进度
        current_time = time.time()
        elapsed_time = current_time - install_start_time
        if elapsed_time < install_duration:
            install_progress = min(95.0, (elapsed_time / install_duration) * 100.0)
        else:
            install_progress = 95.0

        logger.error(f"安装失败时进度: elapsed_time={elapsed_time:.1f}s, install_progress={install_progress:.1f}%")

        update_task(
            task_id=task_id,
            status=TASK_STATUS["FAILED"],
            progress=80.0 + (install_progress / 100.0) * 19.0,
            step_progress=install_progress,
            step="installing",
            message=f"安装失败: {str(e)}",
            error=str(e),
            install_log=install_log
        )

# endregion 配置中心-三方应用管理
