repo_description: 闲鱼智能客服系统，支持多账号管理、AI自动回复、自动发货确认、多渠道消息通知，提供完整的 Web 管理后台
repo_topics: ai auto-reply automation goofish playwright python websocket xianyu
code: """
  自动热更新模块
  
  支持在不重新下载整个exe/容器的情况下，自动更新少量修改的文件。
  
  更新机制：
  1. 从远程服务器获取更新清单（包含文件列表、版本、MD5哈希）
  2. 比较本地文件与远程文件的哈希值
  3. 只下载有变化的文件
  4. 备份旧文件，下载新文件
  5. 需要时重启应用
  
  支持更新的文件类型：
  - Python 源文件 (.py)
  - 前端文件 (.js, .ts, .tsx, .jsx, .vue, .css, .html, .txt, .json)
  - 配置文件 (.yml, .json)
  - 静态资源（图片、字体等）
  """
  
  import os
  import sys
  import json
  import hashlib
  import shutil
  import tempfile
  import asyncio
  import aiohttp
  from pathlib import Path
  from datetime import datetime
  from typing import Dict, List, Optional, Tuple, Any
  from loguru import logger
  from dataclasses import dataclass, asdict
  from enum import Enum
  
  
  class UpdateStatus(Enum):
      """更新状态"""
      IDLE = "idle"                      # 空闲
      CHECKING = "checking"              # 检查中
      DOWNLOADING = "downloading"        # 下载中
      INSTALLING = "installing"          # 安装中
      COMPLETED = "completed"            # 完成
      FAILED = "failed"                  # 失败
      RESTART_REQUIRED = "restart_required"  # 需要重启
  
  
  @dataclass
  class FileUpdate:
      """文件更新信息"""
      path: str                 # 相对路径
      md5: str                  # MD5哈希
      size: int                 # 文件大小
      download_url: str         # 下载URL
      version: str              # 文件版本
      requires_restart: bool    # 是否需要重启
      description: str = ""     # 更新说明
  
  
  @dataclass
  class DeletedFile:
      """待删除文件信息"""
      path: str
      requires_restart: bool
      description: str = ""
  
  
  @dataclass
  class UpdateManifest:
      """更新清单"""
      version: str                      # 版本号
      release_date: str                 # 发布日期
      description: str                  # 版本说明
      files: List[FileUpdate]           # 文件列表
      deleted_files: List[DeletedFile] = None  # 待删除文件列表
      min_version: str = ""             # 最低兼容版本
      changelog: List[str] = None       # 更新日志
  
  
  @dataclass
  class UpdateProgress:
      """更新进度"""
      status: UpdateStatus
      current_file: str = ""
      current_index: int = 0
      total_files: int = 0
      downloaded_bytes: int = 0
      total_bytes: int = 0
      message: str = ""
      error: str = ""
  
  
  class AutoUpdater:
      """自动更新器"""
  
      # 默认 GitHub 更新源配置
      DEFAULT_GITHUB_API_BASE = "https://api.github.com"
      DEFAULT_GITHUB_RAW_BASE = "https://raw.githubusercontent.com"
      DEFAULT_GITHUB_OWNER = "GuDong2003"
      DEFAULT_GITHUB_REPO = "xianyu-auto-reply-fix"
      
      # 可热更新的静态文件类型（通常不需要重启）
      HOT_UPDATABLE_EXTENSIONS = {
          '.css', '.eot', '.gif', '.html', '.ico', '.jpeg', '.jpg',
          '.js', '.json', '.jsx', '.map', '.otf', '.png', '.svg',
          '.ts', '.tsx', '.ttf', '.vue',
          '.txt', '.webp', '.woff', '.woff2', '.yml', '.yaml',
      }
      
      # 需要重启的文件类型
      RESTART_REQUIRED_EXTENSIONS = {'.py', '.pyd', '.so', '.dll', '.exe'}
      
      # 不允许更新的文件/目录
      EXCLUDED_PATHS = {
          'data/',
          'logs/',
          'browser_data/',
          'uploads/',
          '__pycache__/',
          '.git/',
          '.github/',
          '.claude/',
          '.ace-tool/',
          '.pytest_cache/',
          'build/',
          'nginx/',
          'dist/',
          'node_modules/',
          'qr_screenshots/',
          'trajectory_history/',
          'update_backup/',
          'venv/',
          'global_config.yml',  # 用户配置文件不更新
          'update_files.json',
          'Dockerfile',
          'Dockerfile-cn',
          'docker-compose.yml',
          'docker-compose-cn.yml',
      }
      
      def __init__(self, 
                   app_dir: Optional[str] = None,
                   update_server: Optional[str] = None,
                   github_owner: Optional[str] = None,
                   github_repo: Optional[str] = None,
                   github_token: Optional[str] = None,
                   current_version: str = "1.0.0"):
          """
          初始化更新器
          
          Args:
              app_dir: 应用目录，默认为当前工作目录
              update_server: 兼容旧参数，保留但不再作为默认更新源
              github_owner: GitHub 仓库 owner
              github_repo: GitHub 仓库 repo
              github_token: GitHub API Token（可选，用于提升限额）
              current_version: 当前版本号
          """
          self.app_dir = Path(app_dir) if app_dir else Path.cwd()
          self.github_owner = github_owner or os.getenv("UPDATE_GITHUB_OWNER", self.DEFAULT_GITHUB_OWNER)
          self.github_repo = github_repo or os.getenv("UPDATE_GITHUB_REPO", self.DEFAULT_GITHUB_REPO)
          self.github_token = (github_token or os.getenv("UPDATE_GITHUB_TOKEN", "")).strip()
          self.update_server = update_server or f"{self.github_owner}/{self.github_repo}"
          self.current_version = current_version
          self.backup_dir = self.app_dir / "update_backup"
          
          # 更新状态
          self.progress = UpdateProgress(status=UpdateStatus.IDLE)
          self._update_callbacks: List[callable] = []
          
          # 确保备份目录存在
          self.backup_dir.mkdir(parents=True, exist_ok=True)
          
          logger.info(
              "自动更新器初始化: "
              f"app_dir={self.app_dir}, repo={self.github_owner}/{self.github_repo}, version={self.current_version}"
          )
      
      def add_progress_callback(self, callback: callable):
          """添加进度回调"""
          self._update_callbacks.append(callback)
      
      def _notify_progress(self):
          """通知进度更新"""
          for callback in self._update_callbacks:
              try:
                  callback(self.progress)
              except Exception as e:
                  logger.error(f"进度回调执行失败: {e}")
      
      def _update_progress(self, **kwargs):
          """更新进度"""
          for key, value in kwargs.items():
              if hasattr(self.progress, key):
                  setattr(self.progress, key, value)
          self._notify_progress()
      
      def _calculate_file_md5(self, file_path: Path) -> str:
          """计算文件MD5"""
          if not file_path.exists():
              return ""
          
          md5_hash = hashlib.md5()
          with open(file_path, "rb") as f:
              for chunk in iter(lambda: f.read(8192), b""):
                  md5_hash.update(chunk)
          return md5_hash.hexdigest()
      
      def _is_excluded(self, path: str) -> bool:
          """检查路径是否被排除"""
          path_lower = path.lower().replace('\\', '/')
          for excluded in self.EXCLUDED_PATHS:
              if path_lower.startswith(excluded.lower()) or excluded.lower() in path_lower:
                  return True
          return False
      
      def _needs_restart(self, file_path: str) -> bool:
          """检查文件更新是否需要重启"""
          ext = Path(file_path).suffix.lower()
          return ext in self.RESTART_REQUIRED_EXTENSIONS
  
      def refresh_current_version(self) -> str:
          """从本地版本文件刷新当前版本号，避免长生命周期进程读到旧版本"""
          version = self.current_version or "1.0.0"
          version_file = self.app_dir / "static" / "version.txt"
  
          try:
              if version_file.exists():
                  file_version = version_file.read_text(encoding="utf-8").strip()
                  if file_version:
                      version = file_version
          except Exception as e:
              logger.warning(f"读取本地版本文件失败，继续使用缓存版本: {e}")
              return self.current_version
  
          if version != self.current_version:
              logger.info(f"检测到本地版本变更: {self.current_version} -> {version}")
              self.current_version = version
  
          return self.current_version
  
      def _build_request_headers(self, accept_json: bool = True) -> Dict[str, str]:
          """构建 GitHub 请求头"""
          headers = {
              "User-Agent": f"XianyuAutoReplyUpdater/{self.current_version}",
          }
          if accept_json:
              headers["Accept"] = "application/vnd.github+json"
          if self.github_token:
              headers["Authorization"] = f"Bearer {self.github_token}"
          return headers
  
      def _build_latest_release_url(self) -> str:
          """构建 GitHub 最新 release API 地址"""
          return (
              f"{self.DEFAULT_GITHUB_API_BASE}/repos/"
              f"{self.github_owner}/{self.github_repo}/releases/latest"
          )
  
      def _build_raw_file_url(self, tag: str, relative_path: str) -> str:
          """构建 GitHub raw 文件地址"""
          relative_path = relative_path.replace("\\", "/").lstrip("/")
          return (
              f"{self.DEFAULT_GITHUB_RAW_BASE}/"
              f"{self.github_owner}/{self.github_repo}/{tag}/{relative_path}"
          )
  
      def _extract_changelog(self, release_data: Dict[str, Any]) -> List[str]:
          """从 release body 中提取简易更新日志"""
          body = (release_data.get("body") or "").strip()
          if not body:
              return []
  
          changelog = []
          for raw_line in body.splitlines():
              line = raw_line.strip()
              if not line:
                  continue
              if line.startswith(("- ", "* ", "+ ")):
                  line = line[2:].strip()
              changelog.append(line)
          return changelog
  
      def _find_asset_download_url(self, release_data: Dict[str, Any], asset_name: str) -> Optional[str]:
          """查找指定 release asset 的浏览器下载地址"""
          for asset in release_data.get("assets", []) or []:
              if asset.get("name") == asset_name:
                  return asset.get("browser_download_url")
          return None
      
      async def check_for_updates(self) -> Optional[UpdateManifest]:
          """
          检查是否有可用更新
          
          Returns:
              UpdateManifest: 更新清单，如果没有更新则返回None
          """
          self.refresh_current_version()
          self._update_progress(status=UpdateStatus.CHECKING, message="正在检查更新...")
          
          try:
              async with aiohttp.ClientSession() as session:
                  release_url = self._build_latest_release_url()
                  async with session.get(
                      release_url,
                      headers=self._build_request_headers(),
                      timeout=aiohttp.ClientTimeout(total=30)
                  ) as response:
                      if response.status != 200:
                          logger.warning(f"获取 GitHub Release 失败: HTTP {response.status}")
                          self._update_progress(status=UpdateStatus.IDLE, message="检查更新失败")
                          return None
  
                      release_data = await response.json()
  
                  release_tag = (release_data.get("tag_name") or "").strip()
                  if not release_tag:
                      logger.warning("GitHub Release 缺少 tag_name，无法检查更新")
                      self._update_progress(status=UpdateStatus.IDLE, message="检查更新失败")
                      return None
  
                  manifest_url = self._find_asset_download_url(release_data, "update_files.json")
                  if not manifest_url:
                      manifest_url = self._build_raw_file_url(release_tag, "update_files.json")
  
                  async with session.get(
                      manifest_url,
                      headers=self._build_request_headers(accept_json=False),
                      timeout=aiohttp.ClientTimeout(total=30)
                  ) as response:
                      if response.status != 200:
                          logger.warning(f"获取更新清单失败: HTTP {response.status}")
                          self._update_progress(status=UpdateStatus.IDLE, message="检查更新失败")
                          return None
  
                      manifest_text = await response.text()
                      try:
                          manifest_data = json.loads(manifest_text)
                      except json.JSONDecodeError as exc:
                          logger.warning(f"解析更新清单失败: {exc}")
                          self._update_progress(status=UpdateStatus.IDLE, message="检查更新失败")
                          return None
  
                  manifest_version = manifest_data.get("version") or release_tag
                  changelog = manifest_data.get("changelog") or self._extract_changelog(release_data)
  
                  files = []
                  for file_info in manifest_data.get("files", []):
                      file_path = file_info["path"].replace("\\", "/")
                      files.append(FileUpdate(
                          path=file_path,
                          md5=file_info.get("md5", ""),
                          size=file_info.get("size", 0),
                          download_url=self._build_raw_file_url(release_tag, file_path),
                          version=file_info.get("version", manifest_version),
                          requires_restart=file_info.get("requires_restart", self._needs_restart(file_path)),
                          description=file_info.get("description", "")
                      ))
  
                  deleted_files = []
                  for file_info in manifest_data.get("deleted_files", []):
                      file_path = file_info["path"].replace("\\", "/")
                      deleted_files.append(DeletedFile(
                          path=file_path,
                          requires_restart=file_info.get("requires_restart", self._needs_restart(file_path)),
                          description=file_info.get("description", "")
                      ))
  
                  manifest = UpdateManifest(
                      version=manifest_version,
                      release_date=manifest_data.get("release_date") or release_data.get("published_at", ""),
                      description=manifest_data.get("description") or release_data.get("name") or f"版本 {manifest_version} 更新",
                      files=files,
                      deleted_files=deleted_files,
                      min_version=manifest_data.get("min_version", ""),
                      changelog=changelog
                  )
  
                  logger.info(
                      f"发现发布版本: {manifest.version}, 共 {len(files)} 个文件可用于比对更新, "
                      f"{len(deleted_files)} 个文件待删除"
                  )
                  self._update_progress(status=UpdateStatus.IDLE, message=f"已获取版本 {manifest.version} 的更新清单")
  
                  return manifest
                      
          except asyncio.TimeoutError:
              logger.error("检查更新超时")
              self._update_progress(status=UpdateStatus.FAILED, error="检查更新超时")
              return None
          except Exception as e:
              logger.error(f"检查更新失败: {e}")
              self._update_progress(status=UpdateStatus.FAILED, error=str(e))
              return None
      
      async def get_files_to_update(self, manifest: UpdateManifest) -> List[FileUpdate]:
          """
          获取需要更新的文件列表（排除已是最新的文件）
          
          Args:
              manifest: 更新清单
              
          Returns:
              需要更新的文件列表
          """
          files_to_update = []
          excluded_count = 0
          up_to_date_count = 0
          
          for file_update in manifest.files:
              # 跳过被排除的文件
              if self._is_excluded(file_update.path):
                  logger.debug(f"跳过排除的文件: {file_update.path}")
                  excluded_count += 1
                  continue
              
              local_path = self.app_dir / file_update.path
              
              # 如果服务端没有提供MD5，则始终更新该文件
              if not file_update.md5 or not file_update.md5.strip():
                  files_to_update.append(file_update)
                  logger.debug(f"需要更新（无MD5校验）: {file_update.path}")
                  continue
              
              local_md5 = self._calculate_file_md5(local_path)
              
              # 如果本地文件不存在或MD5不匹配，则需要更新
              if local_md5 != file_update.md5:
                  files_to_update.append(file_update)
                  logger.debug(f"需要更新: {file_update.path} (本地MD5: {local_md5}, 远程MD5: {file_update.md5})")
              else:
                  up_to_date_count += 1
  
          logger.info(
              f"更新检查完成: 共 {len(manifest.files)} 个文件, 需要更新 {len(files_to_update)} 个, "
              f"已是最新 {up_to_date_count} 个, 排除 {excluded_count} 个"
          )
          
          return files_to_update
  
      async def get_files_to_delete(self, manifest: UpdateManifest) -> List[DeletedFile]:
          """获取本地实际存在且允许删除的旧文件列表"""
          files_to_delete = []
  
          for deleted_file in manifest.deleted_files or []:
              if self._is_excluded(deleted_file.path):
                  logger.debug(f"跳过排除的删除路径: {deleted_file.path}")
                  continue
  
              local_path = self.app_dir / deleted_file.path
              if local_path.exists() and local_path.is_file():
                  files_to_delete.append(deleted_file)
                  logger.debug(f"需要删除旧文件: {deleted_file.path}")
  
          return files_to_delete
      
      # 非关键文件，MD5校验失败时可以继续更新（仅警告不报错）
      NON_CRITICAL_FILES = {'version.txt', 'update_log.txt', 'changelog.txt'}
      
      async def download_file(self, file_update: FileUpdate, session: aiohttp.ClientSession) -> Optional[bytes]:
          """
          下载单个文件
          
          Args:
              file_update: 文件更新信息
              session: aiohttp会话
              
          Returns:
              文件内容，失败返回None
          """
          try:
              async with session.get(file_update.download_url, timeout=aiohttp.ClientTimeout(total=60)) as response:
                  if response.status != 200:
                      logger.error(f"下载文件失败: {file_update.path}, HTTP {response.status}")
                      return None
                  
                  content = await response.read()
                  
                  # 验证MD5（如果服务端提供了MD5值）
                  if file_update.md5 and file_update.md5.strip():
                      downloaded_md5 = hashlib.md5(content).hexdigest()
                      if downloaded_md5 != file_update.md5:
                          # 检查是否为非关键文件
                          file_name = Path(file_update.path).name
                          if file_name in self.NON_CRITICAL_FILES:
                              logger.warning(f"非关键文件MD5不匹配（忽略）: {file_update.path}, 期望: {file_update.md5}, 实际: {downloaded_md5}")
                              # 非关键文件，继续更新
                          else:
                              logger.error(f"文件MD5校验失败: {file_update.path}, 期望: {file_update.md5}, 实际: {downloaded_md5}")
                              return None
                      else:
                          logger.debug(f"文件MD5校验通过: {file_update.path}")
                  else:
                      logger.debug(f"跳过MD5校验（服务端未提供）: {file_update.path}")
                  
                  return content
                  
          except Exception as e:
              logger.error(f"下载文件异常: {file_update.path}, {e}")
              return None
      
      def _backup_file(self, file_path: Path) -> bool:
          """
          备份文件
          
          Args:
              file_path: 要备份的文件路径
              
          Returns:
              是否成功
          """
          if not file_path.exists():
              return True
          
          try:
              relative_path = file_path.relative_to(self.app_dir)
              backup_path = self.backup_dir / f"{datetime.now().strftime('%Y%m%d_%H%M%S')}" / relative_path
              backup_path.parent.mkdir(parents=True, exist_ok=True)
              shutil.copy2(file_path, backup_path)
              logger.debug(f"备份文件: {file_path} -> {backup_path}")
              return True
          except Exception as e:
              logger.error(f"备份文件失败: {file_path}, {e}")
              return False
  
      def _cleanup_empty_parent_dirs(self, directory: Path):
          """清理因删除文件产生的空目录"""
          current = directory
  
          while current != self.app_dir and current != current.parent:
              try:
                  current.rmdir()
              except OSError:
                  break
  
              current = current.parent
      
      async def apply_updates(self, files_to_update: List[FileUpdate]) -> Tuple[bool, List[str], bool]:
          """
          应用更新
          
          Args:
              files_to_update: 需要更新的文件列表
              
          Returns:
              (是否成功, 更新的文件列表, 是否需要重启)
          """
          if not files_to_update:
              return True, [], False
          
          updated_files = []
          needs_restart = False
          total_size = sum(f.size for f in files_to_update)
          downloaded_size = 0
          
          self._update_progress(
              status=UpdateStatus.DOWNLOADING,
              total_files=len(files_to_update),
              total_bytes=total_size,
              message=f"正在下载 {len(files_to_update)} 个文件..."
          )
          
          async with aiohttp.ClientSession() as session:
              for index, file_update in enumerate(files_to_update):
                  self._update_progress(
                      current_file=file_update.path,
                      current_index=index + 1,
                      message=f"正在下载: {file_update.path}"
                  )
                  
                  # 下载文件
                  content = await self.download_file(file_update, session)
                  if content is None:
                      self._update_progress(
                          status=UpdateStatus.FAILED,
                          error=f"下载文件失败: {file_update.path}"
                      )
                      return False, updated_files, needs_restart
                  
                  downloaded_size += len(content)
                  self._update_progress(downloaded_bytes=downloaded_size)
                  
                  # 备份并安装
                  local_path = self.app_dir / file_update.path
                  
                  # 备份旧文件
                  if not self._backup_file(local_path):
                      logger.warning(f"备份失败，继续更新: {file_update.path}")
                  
                  # 确保目录存在
                  local_path.parent.mkdir(parents=True, exist_ok=True)
                  
                  # 写入新文件
                  try:
                      self._update_progress(
                          status=UpdateStatus.INSTALLING,
                          message=f"正在安装: {file_update.path}"
                      )
                      
                      with open(local_path, 'wb') as f:
                          f.write(content)
                      
                      updated_files.append(file_update.path)
                      
                      if file_update.requires_restart:
                          needs_restart = True
                      
                      logger.info(f"更新文件成功: {file_update.path}")
                      
                  except Exception as e:
                      logger.error(f"写入文件失败: {file_update.path}, {e}")
                      self._update_progress(
                          status=UpdateStatus.FAILED,
                          error=f"安装文件失败: {file_update.path}"
                      )
                      return False, updated_files, needs_restart
          
          # 更新完成
          if needs_restart:
              self._update_progress(
                  status=UpdateStatus.RESTART_REQUIRED,
                  message=f"更新完成，共更新 {len(updated_files)} 个文件，需要重启应用"
              )
          else:
              self._update_progress(
                  status=UpdateStatus.COMPLETED,
                  message=f"更新完成，共更新 {len(updated_files)} 个文件"
              )
          
          return True, updated_files, needs_restart
  
      async def apply_deletions(self, files_to_delete: List[DeletedFile]) -> Tuple[bool, List[str], bool]:
          """
          删除 manifest 中声明的旧文件
  
          Args:
              files_to_delete: 待删除文件列表
  
          Returns:
              (是否成功, 删除的文件列表, 是否需要重启)
          """
          if not files_to_delete:
              return True, [], False
  
          deleted_paths = []
          needs_restart = False
  
          self._update_progress(
              status=UpdateStatus.INSTALLING,
              total_files=len(files_to_delete),
              message=f"正在清理 {len(files_to_delete)} 个旧文件..."
          )
  
          for index, deleted_file in enumerate(files_to_delete):
              self._update_progress(
                  current_file=deleted_file.path,
                  current_index=index + 1,
                  message=f"正在删除旧文件: {deleted_file.path}"
              )
  
              local_path = self.app_dir / deleted_file.path
              if not local_path.exists():
                  continue
  
              if not self._backup_file(local_path):
                  logger.warning(f"删除前备份失败，继续删除: {deleted_file.path}")
  
              try:
                  local_path.unlink()
                  deleted_paths.append(deleted_file.path)
  
                  if deleted_file.requires_restart:
                      needs_restart = True
  
                  self._cleanup_empty_parent_dirs(local_path.parent)
                  logger.info(f"删除旧文件成功: {deleted_file.path}")
              except Exception as e:
                  logger.error(f"删除旧文件失败: {deleted_file.path}, {e}")
                  self._update_progress(
                      status=UpdateStatus.FAILED,
                      error=f"删除旧文件失败: {deleted_file.path}"
                  )
                  return False, deleted_paths, needs_restart
  
          return True, deleted_paths, needs_restart
      
      async def perform_update(self, manifest: Optional[UpdateManifest] = None) -> Dict[str, Any]:
          """
          执行完整的更新流程
          
          Args:
              manifest: 更新清单，如果为None则自动检查
              
          Returns:
              更新结果
          """
          result = {
              "success": False,
              "message": "",
              "updated_files": [],
              "deleted_files": [],
              "needs_restart": False,
              "new_version": ""
          }
          
          try:
              self.refresh_current_version()
              # 检查更新
              if manifest is None:
                  manifest = await self.check_for_updates()
              
              if manifest is None:
                  result["message"] = "没有可用更新"
                  result["success"] = True
                  return result
              
              result["new_version"] = manifest.version
              
              # 获取需要更新的文件
              files_to_update = await self.get_files_to_update(manifest)
              files_to_delete = await self.get_files_to_delete(manifest)
              
              if not files_to_update and not files_to_delete:
                  result["message"] = "所有文件已是最新"
                  result["success"] = True
                  return result
              
              logger.info(
                  f"开始更新到版本 {manifest.version}: {len(files_to_update)} 个文件更新, "
                  f"{len(files_to_delete)} 个文件待删除"
              )
              
              # 应用更新
              success, updated_files, needs_restart = await self.apply_updates(files_to_update)
              deleted_files: List[str] = []
  
              if success:
                  success, deleted_files, delete_restart = await self.apply_deletions(files_to_delete)
                  needs_restart = needs_restart or delete_restart
              
              result["success"] = success
              result["updated_files"] = updated_files
              result["deleted_files"] = deleted_files
              result["needs_restart"] = needs_restart
              
              if success:
                  message_parts = []
                  if updated_files:
                      message_parts.append(f"更新 {len(updated_files)} 个文件")
                  if deleted_files:
                      message_parts.append(f"删除 {len(deleted_files)} 个旧文件")
  
                  result["message"] = f"成功{'，'.join(message_parts) if message_parts else '处理变更'}到版本 {manifest.version}"
                  if needs_restart:
                      result["message"] += "，需要重启应用生效"
                  
                  # 更新成功后，保存文件哈希清单（用于以后对比）
                  self.save_file_hashes(manifest.version, updated_files, deleted_files)
              else:
                  result["message"] = "更新过程中出现错误"
              
              return result
              
          except Exception as e:
              logger.error(f"更新失败: {e}")
              result["message"] = f"更新失败: {str(e)}"
              self._update_progress(status=UpdateStatus.FAILED, error=str(e))
              return result
      
      def get_local_file_hashes(self, file_patterns: List[str] = None) -> Dict[str, str]:
          """
          获取本地文件的MD5哈希值
          
          Args:
              file_patterns: 文件模式列表，默认为常见的可更新文件
              
          Returns:
              {文件路径: MD5哈希}
          """
          if file_patterns is None:
              try:
                  from generate_update_manifest import collect_updatable_files
  
                  file_hashes = {}
                  for relative_path in collect_updatable_files(self.app_dir):
                      if self._is_excluded(relative_path):
                          continue
  
                      file_path = self.app_dir / relative_path
                      file_hashes[relative_path] = self._calculate_file_md5(file_path)
  
                  return file_hashes
              except Exception as e:
                  logger.debug(f"使用 manifest 扫描规则获取本地哈希失败，回退到后缀模式: {e}")
  
              file_patterns = [
                  '*.py', '*.js', '*.ts', '*.tsx', '*.jsx', '*.vue',
                  '*.css', '*.html', '*.txt', '*.json', '*.map',
                  '*.yml', '*.yaml', '*.png', '*.jpg', '*.jpeg',
                  '*.gif', '*.svg', '*.ico', '*.webp', '*.woff',
                  '*.woff2', '*.eot', '*.otf', '*.ttf',
              ]
          
          file_hashes = {}
          
          for pattern in file_patterns:
              for file_path in self.app_dir.rglob(pattern):
                  try:
                      relative_path = str(file_path.relative_to(self.app_dir)).replace('\\', '/')
                      
                      # 跳过排除的路径
                      if self._is_excluded(relative_path):
                          continue
                      
                      file_hashes[relative_path] = self._calculate_file_md5(file_path)
                  except Exception as e:
                      logger.debug(f"计算文件哈希失败: {file_path}, {e}")
          
          return file_hashes
      
      def cleanup_old_backups(self, keep_days: int = 7):
          """
          清理旧的备份文件
          
          Args:
              keep_days: 保留天数
          """
          try:
              cutoff_time = datetime.now().timestamp() - (keep_days * 24 * 60 * 60)
              
              for backup_dir in self.backup_dir.iterdir():
                  if backup_dir.is_dir():
                      if backup_dir.stat().st_mtime < cutoff_time:
                          shutil.rmtree(backup_dir)
                          logger.info(f"清理旧备份: {backup_dir}")
                          
          except Exception as e:
              logger.error(f"清理备份失败: {e}")
      
      def save_file_hashes(
          self,
          version: str,
          updated_files: List[str] = None,
          deleted_files: List[str] = None,
      ):
          """
          保存文件哈希清单到本地
          
          更新完成后调用此方法，记录所有文件的MD5哈希值，
          方便以后对比哪些文件发生了变化。
          
          Args:
              version: 当前版本号
              updated_files: 本次更新的文件列表（可选）
          """
          try:
              hash_file = self.app_dir / "data" / "file_hashes.json"
              hash_file.parent.mkdir(parents=True, exist_ok=True)
              
              # 获取所有可更新文件的哈希
              all_hashes = self.get_local_file_hashes()
              
              # 构建哈希清单
              manifest = {
                  "version": version,
                  "updated_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
                  "total_files": len(all_hashes),
                  "files": all_hashes
              }
              
              # 如果有本次更新的文件列表，单独记录
              if updated_files:
                  manifest["last_updated_files"] = updated_files
                  manifest["last_updated_count"] = len(updated_files)
              if deleted_files:
                  manifest["last_deleted_files"] = deleted_files
                  manifest["last_deleted_count"] = len(deleted_files)
              
              # 保存到文件
              with open(hash_file, 'w', encoding='utf-8') as f:
                  json.dump(manifest, f, ensure_ascii=False, indent=2)
              
              logger.info(f"已保存文件哈希清单: {hash_file}, 共 {len(all_hashes)} 个文件")
              
          except Exception as e:
              logger.error(f"保存文件哈希清单失败: {e}")
      
      def load_file_hashes(self) -> Optional[Dict[str, Any]]:
          """
          加载本地保存的文件哈希清单
          
          Returns:
              哈希清单字典，如果不存在则返回None
          """
          try:
              hash_file = self.app_dir / "data" / "file_hashes.json"
              
              if not hash_file.exists():
                  return None
              
              with open(hash_file, 'r', encoding='utf-8') as f:
                  return json.load(f)
                  
          except Exception as e:
              logger.error(f"加载文件哈希清单失败: {e}")
              return None
      
      def compare_file_hashes(self) -> Dict[str, Any]:
          """
          比较当前文件与保存的哈希清单
          
          Returns:
              比较结果，包含变化的文件列表
          """
          result = {
              "has_changes": False,
              "saved_version": None,
              "changed_files": [],
              "new_files": [],
              "deleted_files": [],
              "unchanged_files": []
          }
          
          try:
              saved_manifest = self.load_file_hashes()
              
              if saved_manifest is None:
                  result["message"] = "没有保存的哈希清单，无法比较"
                  return result
              
              result["saved_version"] = saved_manifest.get("version")
              saved_hashes = saved_manifest.get("files", {})
              
              # 获取当前文件哈希
              current_hashes = self.get_local_file_hashes()
              
              # 比较文件
              all_files = set(saved_hashes.keys()) | set(current_hashes.keys())
              
              for file_path in all_files:
                  saved_md5 = saved_hashes.get(file_path)
                  current_md5 = current_hashes.get(file_path)
                  
                  if saved_md5 is None:
                      # 新增的文件
                      result["new_files"].append(file_path)
                  elif current_md5 is None:
                      # 删除的文件
                      result["deleted_files"].append(file_path)
                  elif saved_md5 != current_md5:
                      # 修改的文件
                      result["changed_files"].append({
                          "path": file_path,
                          "old_md5": saved_md5,
                          "new_md5": current_md5
                      })
                  else:
                      # 未变化的文件
                      result["unchanged_files"].append(file_path)
              
              result["has_changes"] = bool(result["changed_files"] or result["new_files"] or result["deleted_files"])
              result["message"] = f"比较完成: {len(result['changed_files'])} 个文件修改, {len(result['new_files'])} 个新增, {len(result['deleted_files'])} 个删除"
              
          except Exception as e:
              logger.error(f"比较文件哈希失败: {e}")
              result["message"] = f"比较失败: {str(e)}"
          
          return result
  
  
  # 全局更新器实例
  _updater: Optional[AutoUpdater] = None
  
  
  def get_updater() -> AutoUpdater:
      """获取全局更新器实例"""
      global _updater
      if _updater is None:
          # 尝试从版本文件读取当前版本
          version = "1.0.0"
          try:
              version_file = Path(__file__).parent / "static" / "version.txt"
              if version_file.exists():
                  version = version_file.read_text().strip()
          except:
              pass
          
          _updater = AutoUpdater(current_version=version)
      
      return _updater
  
  
  def init_updater(app_dir: str = None, update_server: str = None, current_version: str = None) -> AutoUpdater:
      """
      初始化全局更新器
      
      Args:
          app_dir: 应用目录
          update_server: 更新服务器地址
          current_version: 当前版本号
      """
      global _updater
      
      if current_version is None:
          try:
              version_file = Path(app_dir or ".") / "static" / "version.txt"
              if version_file.exists():
                  current_version = version_file.read_text().strip()
              else:
                  current_version = "1.0.0"
          except:
              current_version = "1.0.0"
      
      _updater = AutoUpdater(
          app_dir=app_dir,
          update_server=update_server,
          current_version=current_version
      )
      
      return _updater
  