// +---------------------------------------------------------------------- namespace app\services\system; use app\services\system\log\SystemFileMd5Services; use SplFileInfo; use think\facade\Config; use think\facade\Db; use think\facade\Log; use app\jobs\UpgradeJob; use app\services\BaseServices; use crmeb\services\FileService; use crmeb\services\HttpService; use crmeb\services\CacheService; use crmeb\utils\fileVerification; use crmeb\exceptions\AdminException; use app\dao\system\upgrade\UpgradeLogDao; /** * 在线升级 * Class UpgradeServices * @package app\services\system */ class UpgradeServices extends BaseServices { const LOGIN_URL = 'http://upgrade.crmeb.net/api/login'; const UPGRADE_URL = 'http://upgrade.crmeb.net/api/upgrade/list'; const UPGRADE_CURRENT_URL = 'http://upgrade.crmeb.net/api/upgrade/current_list'; const AGREEMENT_URL = 'http://upgrade.crmeb.net/api/upgrade/agreement'; const PACKAGE_DOWNLOAD_URL = 'http://upgrade.crmeb.net/api/upgrade/download'; const UPGRADE_STATUS_URL = 'http://upgrade.crmeb.net/api/upgrade/status'; const UPGRADE_LOG_URL = 'http://upgrade.crmeb.net/api/upgrade/log'; /** * @var array $requestData */ private $requestData = []; /** * @var int $timeStamp */ private $timeStamp = 0; /** * UpgradeServices constructor. * @param UpgradeLogDao $dao */ public function __construct(UpgradeLogDao $dao) { $this->dao = $dao; $versionData = $this->getVersion(); // if ($versionData['version_code'] < 450) return true; if (empty($versionData)) { throw new AdminException('授权信息丢失'); } $this->timeStamp = time(); $recVersion = $this->recombinationVersion($versionData['version'] ?? ''); $this->requestData = [ 'nonce' => mt_rand(111, 999), 'host' => app()->request->host(), 'timestamp' => $this->timeStamp, 'app_id' => trim($versionData['app_id'] ?? ''), 'app_key' => trim($versionData['app_key'] ?? ''), 'version' => implode('.', $recVersion) ]; if (!CacheService::get('upgrade_auth_token')) { $this->getAuth(); } } /** * 获取版本信息 * @return void */ /** * 获取文件配置信息 * @param string $name * @param string $path * @return array|string */ public function getVersion(string $name = '', string $path = '') { $file = '.version'; $arr = []; $list = @file($path ?: app()->getRootPath() . $file); foreach ($list as $val) { list($k, $v) = explode('=', str_replace(PHP_EOL, '', $val)); $arr[$k] = $v; } return !empty($name) ? $arr[$name] ?? '' : $arr; } /** * 获取版本号 * @param $input * @return array */ public function recombinationVersion($input): array { $version = substr($input, strpos($input, ' v') + 1); return array_map(function ($item) { if (preg_match('/\d+/', $item, $arr)) { $item = $arr[0]; } return (int)$item; }, explode('.', $version)); } /** * 获取Token * @return void */ public function getAuth() { $this->getSign($this->timeStamp); $result = HttpService::postRequest(self::LOGIN_URL, $this->requestData); if (!$result) { throw new AdminException('授权失败'); } $authData = json_decode($result, true); if (!isset($authData['status']) || $authData['status'] != 200) { Log::error(['msg' => $authData['msg'] ?? '', 'error' => $authData['data'] ?? []]); throw new AdminException($authData['msg'] ?? '授权失败'); } CacheService::set('upgrade_auth_token', $authData['data']['access_token'], 7200); } /** * 获取签名 * @param int $timeStamp * @return void */ public function getSign(int $timeStamp) { $data = $this->requestData; if ((!isset($data['host']) || !$data['host']) || (!isset($data['nonce']) || !$data['nonce']) || (!isset($data['app_id']) || !$data['app_id']) || (!isset($data['version']) || !$data['version']) || (!isset($data['app_key']) || !$data['app_key']) ) { throw new AdminException('验证失效,请重新请求'); } $host = $data['host']; $nonce = $data['nonce']; $appId = $data['app_id']; $appKey = $data['app_key']; $version = $data['version']; unset($data['sign'], $data['nonce'], $data['host'], $data['version'], $data['app_id'], $data['app_key']); $params = json_encode($data); $shaiAtt = [ 'host' => $host, 'nonce' => $nonce, 'app_id' => $appId, 'params' => $params, 'app_key' => $appKey, 'version' => $version, 'time_stamp' => $timeStamp ]; sort($shaiAtt, SORT_STRING); $shaiStr = implode(',', $shaiAtt); $this->requestData['sign'] = hash("SHA256", $shaiStr); } /** * 升级列表 * @return mixed */ public function getUpgradeList() { [$page, $limit] = $this->getPageValue(); $this->requestData['page'] = (string)($page ?: 1); $this->requestData['limit'] = (string)($limit ?: 10); $this->getSign($this->timeStamp); $result = HttpService::getRequest(self::UPGRADE_URL, $this->requestData); if (!$result) { throw new AdminException('升级列表获取失败'); } $data = json_decode($result, true); if (!$this->checkAuth($data)) { throw new AdminException($data['msg'] ?? '升级列表获取失败'); } return $data['data'] ?? []; } /** * 可升级列表 * @return mixed */ public function getUpgradeableList() { $this->getSign($this->timeStamp); $result = HttpService::getRequest(self::UPGRADE_CURRENT_URL, $this->requestData, ['Access-Token: Bearer ' . CacheService::get('upgrade_auth_token')]); if (!$result) { throw new AdminException('可升级列表获取失败'); } $data = json_decode($result, true); if (!$this->checkAuth($data)) { throw new AdminException($data['msg'] ?? '升级列表获取失败'); } if ($data['data']) { $routineData = [ 'version' => $data['data'][0]['first_version'] . '.' . $data['data'][0]['second_version'] . '.' . $data['data'][0]['third_version'], 'desc' => $data['data'][0]['content'], 'is_live' => 0 ]; CacheService::set('routine_upload_data', $routineData, 86400); } return $data['data'] ?? []; } /** * 升级协议 * @return mixed */ public function getAgreement() { $this->getSign($this->timeStamp); $result = HttpService::getRequest(self::AGREEMENT_URL, $this->requestData, ['Access-Token: Bearer ' . CacheService::get('upgrade_auth_token')]); if (!$result) { throw new AdminException('升级协议获取失败'); } $data = json_decode($result, true); if (!$this->checkAuth($data)) { throw new AdminException($data['msg'] ?? '升级协议获取失败'); } return $data['data'] ?? []; } /** * 下载 * @param string $packageKey * @return bool */ public function packageDownload(string $packageKey): bool { $token = md5(time()); //检查数据库大小 $this->checkDatabaseSize(); $this->requestData['package_key'] = $packageKey; $this->getSign($this->timeStamp); $result = HttpService::getRequest(self::PACKAGE_DOWNLOAD_URL, $this->requestData, ['Access-Token: Bearer ' . CacheService::get('upgrade_auth_token')]); if (!$result) { throw new AdminException('升级包获取失败'); } $data = json_decode($result, true); if (!$this->checkAuth($data)) { throw new AdminException($data['msg'] ?? '授权失败'); } if (empty($data['data']['server_package_link']) && empty($data['data']['client_package_link']) && empty($data['data']['pc_package_link'])) { CacheService::set($token . 'upgrade_status', 2, 86400); return true; } if (!empty($data['data']['server_package_link'])) { $this->downloadFile($data['data']['server_package_link'], $token . '_server_package'); } else { CacheService::set($token . '_server_package', 2, 86400); } if (!empty($data['data']['client_package_link'])) { $this->downloadFile($data['data']['client_package_link'], $token . '_client_package'); } else { CacheService::set($token . '_client_package', 2, 86400); } if (!empty($data['data']['pc_package_link'])) { $this->downloadFile($data['data']['pc_package_link'], $token . '_pc_package'); } else { CacheService::set($token . '_pc_package', 2, 86400); } CacheService::set('upgrade_token', $token, 86400); CacheService::set($token . '_upgrade_data', $data, 86400); return true; } /** * 执行下载 * @param string $seq * @param string $url * @param string $downloadPath * @param string $fileName * @param int $timeout * @return void */ public function download(string $seq, string $url, string $downloadPath, string $fileName, int $timeout = 300) { ini_set('memory_limit', '-1'); $filePath = $downloadPath . DS . $fileName; $fp_output = fopen($filePath, 'w'); if (!$fp_output) { throw new AdminException('无法创建下载文件'); } $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, false); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30); // 连接超时 curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); // 总超时时间 curl_setopt($ch, CURLOPT_FILE, $fp_output); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); // 跟随重定向 curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'); curl_setopt($ch, CURLOPT_REFERER, 'https://www.crmeb.com'); if (stripos($url, "https://") !== false) { curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); } $result = curl_exec($ch); $error = curl_error($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); fclose($fp_output); // 关闭文件句柄 // 检查下载结果 if ($result === false || !empty($error)) { @unlink($filePath); throw new AdminException('下载失败: ' . $error); } if ($httpCode !== 200) { @unlink($filePath); throw new AdminException('下载失败,HTTP状态码: ' . $httpCode); } // 检查文件是否存在且有内容 if (!is_file($filePath) || filesize($filePath) < 100) { @unlink($filePath); throw new AdminException('下载的文件无效'); } if (pathinfo($fileName, PATHINFO_EXTENSION) !== 'zip') { throw new AdminException('安装包格式错误'); } /** @var FileService $fileService */ $fileService = app()->make(FileService::class); $downloadFilePath = $downloadPath . DS . pathinfo($fileName, PATHINFO_FILENAME); if (!$fileService->extractFile($filePath, $downloadFilePath)) { throw new AdminException('升级包解压失败'); } CacheService::set($seq . '_path', $downloadFilePath, 86400); CacheService::set($seq . '_name', $filePath, 86400); CacheService::set($seq, 2, 86400); } /** * 开始下载 * @param string $packageLink * @param string $seq * @return void */ private function downloadFile(string $packageLink, string $seq) { $fileName = substr($packageLink, strrpos($packageLink, '/') + 1); $filePath = app()->getRootPath() . 'upgrade' . DS . date('Y-m-d'); if (!is_dir($filePath)) mkdir($filePath, 0755, true); UpgradeJob::dispatch('download', [$seq, $packageLink, $filePath, $fileName, 300]); CacheService::set($seq, 1, 86400); } /** * 升级进度 * @return array */ public function getProgress(): array { $token = CacheService::get('upgrade_token'); if (empty($token)) { throw new AdminException('请重新升级'); } $serverProgress = CacheService::get($token . '_server_package'); // 服务端包下载进度 $clientProgress = CacheService::get($token . '_client_package'); // 客户端包下载进度 $pcProgress = CacheService::get($token . '_pc_package'); // PC端包下载进度 $databaseBackupProgress = CacheService::get($token . '_database_backup'); // 数据库备份进度 $projectBackupProgress = CacheService::get($token . '_project_backup'); // 项目备份备份进度 $databaseUpgradeProgress = CacheService::get($token . '_database_upgrade'); // 数据库升级进度 $coverageProjectProgress = CacheService::get($token . '_coverage_project'); // 项目覆盖进度 $stepNum = 1; $tip = '开始升级'; if ($serverProgress == $clientProgress && $clientProgress == $pcProgress) { $tip = $serverProgress == 1 ? '开始下载安装包' : '安装包下载完成'; if ($serverProgress == 2) { $stepNum += 1; } } else { $tip = '正在下载安装包'; } if ($databaseBackupProgress == 2) { $tip = '数据库备份完成'; $stepNum += 1; } if ($projectBackupProgress == 2) { $tip = '项目备份完成'; $stepNum += 1; } if ((int)$databaseUpgradeProgress == 2) { $tip = '数据库升级完成'; $stepNum += 1; } if ((int)$coverageProjectProgress == 2) { $tip = '项目升级完成'; $stepNum += 1; } $upgradeStatus = (int)CacheService::get($token . 'upgrade_status'); if ($upgradeStatus == 2) { $stepNum = 6; $tip = '升级完成'; } elseif ($upgradeStatus < 0) { $this->saveLog($token); throw new AdminException(CacheService::get($token . 'upgrade_status_tip', '升级失败')); } elseif ($serverProgress == 2 && $clientProgress == 2 && $pcProgress == 2 && $databaseBackupProgress == 2 && $projectBackupProgress == 2) { try { $this->overwriteProject(); } catch (\Exception $e) { $this->sendUpgradeLog($token); } } $speed = sprintf("%.1f", $stepNum / 6 * 100); return compact('speed', 'tip'); } /** * 数据库备份 * @param $token * @return bool * @throws \think\db\exception\BindParamException */ public function databaseBackup($token): bool { try { //备份表数据 /** @var SystemDatabackupServices $backServices */ $backServices = app()->make(SystemDatabackupServices::class); $tables = $backServices->getDataList(); if (count($tables['list']) < 1) { throw new AdminException('数据表获取失败'); } // 从.version文件获取版本号 $versionData = $this->getVersion(); $backServices->getDbBackup()->setFile(['name' => $versionData['version_code'], 'part' => 1]); $tables = implode(',', array_column($tables['list'], 'name')); $result = $backServices->backup($tables); if (!empty($result)) { throw new AdminException('数据库备份失败 ' . $result); } $fileData = $backServices->getDbBackup()->getFile(); $fileName = $fileData['filename'] . '.gz'; if (!is_file($fileData['filepath'] . $fileName)) { throw new AdminException('数据库备份失败'); } CacheService::set($token . '_database_backup', 2, 86400); CacheService::set($token . '_database_backup_name', $fileName, 86400); return true; } catch (\Exception $e) { Log::error('升级失败,失败原因:' . $e->getMessage()); CacheService::set($token . 'upgrade_status', -1, 86400); CacheService::set($token . 'upgrade_status_tip', '升级失败,失败原因:' . $e->getMessage(), 86400); } return false; } /** * 项目备份 * @param string $token * @return bool */ public function projectBackup(string $token): bool { try { ini_set('memory_limit', '-1'); $appPath = app()->getRootPath(); /** @var FileService $fileService */ $fileService = app()->make(FileService::class); $dir = 'backup' . DS . date('Ymd') . DS . $token; $backupDir = $appPath . $dir; $fileService->handleDir($appPath . 'app', $backupDir . DS . 'app'); $fileService->handleDir($appPath . 'config', $backupDir . DS . 'config'); $fileService->handleDir($appPath . 'crmeb', $backupDir . DS . 'crmeb'); // 从.version文件获取版本号 $versionData = $this->getVersion(); $fileName = $versionData['version_code'] . '-1.project.zip'; $filePath = $appPath . 'backup' . DS . $fileName; /** @var FileService $fileService */ $fileService = app()->make(FileService::class); $result = $fileService->addZip($backupDir, $filePath, $backupDir); if (!$result) { throw new AdminException('项目备份失败'); } CacheService::set($token . '_project_backup', 2, 86400); CacheService::set($token . '_project_backup_name', $fileName, 86400); //检测项目备份 if (!is_file($filePath)) { throw new AdminException('项目备份检测失败'); } // 压缩完成,删除移动的文件 $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($appPath . 'backup' . DS . date('Ymd'), \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST ); /** @var SplFileInfo $fileInfo */ foreach ($iterator as $fileInfo) { if ($fileInfo->isDir()) { @rmdir($fileInfo->getRealPath()); } else { @unlink($fileInfo->getRealPath()); } } return true; } catch (\Exception $e) { Log::error('升级失败,失败原因:' . $e->getMessage()); CacheService::set($token . 'upgrade_status', -1, 86400); CacheService::set($token . 'upgrade_status_tip', '升级失败,失败原因:' . $e->getMessage(), 86400); } return false; } /** * 升级 * @return bool * @throws \Exception */ public function overwriteProject(): bool { try { if (!$token = CacheService::get('upgrade_token')) { throw new AdminException('请重新下载升级包'); } if (CacheService::get($token . 'is_execute') == 2) { return true; } CacheService::set($token . 'is_execute', 2, 86400); $dataBackupName = CacheService::get($token . '_database_backup_name'); if (!$dataBackupName || !is_file(app()->getRootPath() . 'backup' . DS . $dataBackupName)) { throw new AdminException('数据库备份失败'); } $serverPackageFilePath = CacheService::get($token . '_server_package_path'); if (!is_dir($serverPackageFilePath)) { throw new AdminException('项目文件获取异常'); } // 执行sql文件 if (!$this->databaseUpgrade($token, $serverPackageFilePath)) { throw new AdminException('数据库升级失败'); } // 替换文件目录 $this->coverageProject($token); // 发送升级日志 $this->sendUpgradeLog($token); $this->saveLog($token); CacheService::set($token . 'upgrade_status', 2, 86400); return true; } catch (\Exception $e) { Log::error('升级失败,失败原因:' . $e->getMessage()); CacheService::set($token . 'upgrade_status', -1, 86400); CacheService::set($token . 'upgrade_status_tip', '升级失败,失败原因:' . $e->getMessage(), 86400); } return false; } /** * 写入日志 * @param $token * @return void */ public function saveLog($token) { if (CacheService::get($token . 'is_save') == 2) { return true; } CacheService::set($token . 'is_save', 2, 86400); $upgradeData = CacheService::get($token . '_upgrade_data'); $this->dao->save([ 'title' => $upgradeData['data']['title'] ?? '', 'content' => $upgradeData['data']['content'] ?? '', 'first_version' => $upgradeData['data']['first_version'] ?? '', 'second_version' => $upgradeData['data']['second_version'] ?? '', 'third_version' => $upgradeData['data']['third_version'] ?? '', 'fourth_version' => $upgradeData['data']['fourth_version'] ?? '', 'upgrade_time' => time(), 'error_data' => CacheService::get($token . 'upgrade_status_tip', ''), 'package_link' => CacheService::get($token . '_project_backup_name', ''), 'data_link' => CacheService::get($token . '_database_backup_name', '') ]); } /** * 发送日志 * @param string $token * @return bool */ public function sendUpgradeLog(string $token): bool { try { $versionBefore = CacheService::get('version_before', ''); $versionData = $this->getVersion(); if (empty($versionData)) { throw new AdminException('授权信息丢失'); } $versionAfter = $this->recombinationVersion($versionData['version'] ?? ''); $this->requestData['version_before'] = implode('.', $versionBefore); $this->requestData['version_after'] = implode('.', $versionAfter); $this->requestData['error_data'] = CacheService::get($token . 'upgrade_status_tip', ''); $this->getSign($this->timeStamp); $result = HttpService::postRequest(self::UPGRADE_LOG_URL, $this->requestData, ['Access-Token: Bearer ' . CacheService::get('upgrade_auth_token')]); if (!$result) { throw new AdminException('升级日志推送失败'); } $data = json_decode($result, true); $this->checkAuth($data); } catch (\Exception $e) { Log::error(['msg' => '升级日志发送失败:,失败原因' . ($data['msg'] ?? '') . $e->getMessage(), 'data' => $data]); } return true; } /** * 数据库升级 * @param string $token * @param string $serverPackageFilePath * @return bool */ public function databaseUpgrade(string $token, string $serverPackageFilePath): bool { $databaseFilePath = $serverPackageFilePath . DS . "upgrade" . DS . "database.php"; if (!is_file($databaseFilePath)) { CacheService::set($token . '_database_upgrade', 2, 86400); return true; } CacheService::set($token . '_database_upgrade', 1, 86400); $sqlData = include $databaseFilePath; $nowCode = $this->getVersion('version_code'); if ($sqlData['new_code'] <= $nowCode) { CacheService::set($token . '_database_upgrade', 2, 86400); return true; } $updateSql = $upgradeSql = []; foreach ($sqlData['update_sql'] as $items) { if ($items['code'] > $nowCode) { $upgradeSql[] = $items; } } if (empty($upgradeSql)) { CacheService::set($token . '_database_upgrade', 2, 86400); return true; } $prefix = config('database.connections.' . config('database.default'))['prefix']; Db::startTrans(); try { foreach ($upgradeSql as $item) { $tip = [ '1' => '表已存在', '2' => '表不存在', '3' => '表中' . ($item['field'] ?? '') . '字段已存在', '4' => '表中' . ($item['field'] ?? '') . '字段不存在', '5' => '表中删除字段' . ($item['field'] ?? '') . '不存在', '6' => '表中数据已存在', '6_2' => '表中查询父类ID不存在', '7' => '表中数据已存在', '8' => '表中数据不存在', ]; if (!isset($item['table']) || !$item['table']) { throw new AdminException('请核对升级数据结构:table'); } if (!isset($item['sql']) || !$item['sql']) { throw new AdminException('请核对升级数据结构:sql'); } $whereTable = ''; $table = $prefix . $item['table']; if (isset($item['whereTable']) && $item['whereTable']) { $whereTable = $prefix . $item['whereTable']; } if (isset($item['findSql']) && $item['findSql']) { $findSql = str_replace('@table', $table, $item['findSql']); if (!empty(Db::query($findSql))) { // 1建表 2删表 3添加字段 4修改字段 5删除字段 6添加数据 7修改数据 8删数据 -1直接执行 // 表/字段/数据已存在时跳过,不中断升级 if (in_array($item['type'], [1, 3, 6])) { Log::notice(['type' => 'database_upgrade_skip', 'reason' => $table . ($tip[$item['type']] ?? ''), 'item' => json_encode($item)]); continue; } } else { // 表/字段/数据不存在时跳过修改和删除操作 if (in_array($item['type'], [4, 5, 7])) { Log::notice(['type' => 'database_upgrade_skip', 'reason' => $table . ($tip[$item['type']] ?? ''), 'item' => json_encode($item)]); continue; } if ($item['type'] == 8) { continue; } } } if ($item['type'] == 4) { if (!isset($item['rollback_sql']) || !$item['rollback_sql']) { throw new AdminException('请核对升级数据结构:rollback_sql'); } $updateSql[] = $item; } $upSql = str_replace('@table', $table, $item['sql']); if ($item['type'] == 6 || $item['type'] == 7) { if (isset($item['whereSql']) && $item['whereSql']) { $whereSql = str_replace('@whereTable', $whereTable, $item['whereSql']); $tabId = Db::query($whereSql)[0]['tabId'] ?? 0; if (!$tabId) { // 关联数据不存在时跳过,不中断升级 Log::notice(['type' => 'database_upgrade_skip', 'reason' => $table . ' 关联数据不存在', 'item' => json_encode($item)]); continue; } $upSql = str_replace('@tabId', $tabId, $upSql); } } elseif ($item['type'] == 8) { $upSql = str_replace(['@table', '@field', '@value'], [$table, $item['field'], $item['value']], $item['sql']); } elseif ($item['type'] == -1) { if (isset($item['new_table']) && $item['new_table']) { $new_table = $prefix . $item['new_table']; $upSql = str_replace('@new_table', $new_table, $upSql); } } if ($upSql) { Db::execute($upSql); } Log::write(['type' => 'database_upgrade', '`item' => json_encode($item), 'upSql' => $upSql], 'notice'); } Db::commit(); CacheService::set($token . '_database_upgrade', 2, 86400); } catch (\Throwable $e) { Db::rollback(); Log::error(['msg' => '数据库升级失败,失败原因:' . $e->getMessage(), 'data' => json_encode($upgradeSql)]); CacheService::set($token . 'upgrade_status', -1, 86400); CacheService::set($token . 'upgrade_status_tip', '数据库升级失败,失败原因:' . $e->getMessage(), 86400); if (!empty($updateSql)) { $this->rollbackStructure($prefix, $updateSql); } return false; } return true; } /** * 覆盖项目 * @param string $token * @return bool */ public function coverageProject(string $token): bool { $versionData = $this->getVersion(); if (empty($versionData)) { throw new AdminException('授权信息异常'); } CacheService::set('version_before', $this->recombinationVersion($versionData['version'] ?? ''), 86400); /** @var FileService $fileService */ $fileService = app()->make(FileService::class); // 服务端项目 $serverPackageName = CacheService::get($token . '_server_package_name'); // 客户端项目 $clientPackageName = CacheService::get($token . '_client_package_name'); // PC端项目 $pcPackageName = CacheService::get($token . '_pc_package_name'); if (!is_file($serverPackageName) && !is_file($clientPackageName) && !is_file($pcPackageName)) { throw new AdminException('升级文件异常,请重新下载'); } if (is_file($serverPackageName) && !$fileService->extractFile($serverPackageName, app()->getRootPath())) { throw new AdminException('服务端解压失败'); } if (is_file($clientPackageName) && !$fileService->extractFile($clientPackageName, app()->getRootPath())) { throw new AdminException('客户端解压失败'); } if (is_file($pcPackageName) && !$fileService->extractFile($pcPackageName, app()->getRootPath())) { throw new AdminException('PC端解压失败'); } CacheService::set($token . '_coverage_project', 2, 86400); return true; } /** * 回滚表结构 * @param string $prefix * @param array $updateSql * @return void */ public function rollbackStructure(string $prefix, array $updateSql): void { try { foreach ($updateSql as $item) { Db::execute(str_replace('@table', $prefix . $item['table'], $item['rollback_sql'])); } } catch (\Exception $e) { Log::error(['msg' => '数据库结构回滚失败', 'error' => $e->getFile() . '__' . $e->getLine() . '__' . $e->getMessage(), 'data' => $updateSql]); } } /** * 恢复数据库备份 * @param string $backupFileName 备份文件名 * @return bool */ public function restoreDatabase(string $backupFileName): bool { try { $backupPath = app()->getRootPath() . 'backup' . DS . $backupFileName; if (!is_file($backupPath)) { throw new AdminException('数据库备份文件不存在'); } // 检测是否是 gz 压缩文件 $isGz = (pathinfo($backupFileName, PATHINFO_EXTENSION) === 'gz'); // 直接读取并执行 SQL 文件 $db = \think\facade\Db::connect(); if ($isGz) { $gz = gzopen($backupPath, 'r'); if (!$gz) { throw new AdminException('无法打开备份文件'); } $sql = ''; while (!gzeof($gz)) { $line = gzgets($gz); // 跳过注释和空行 if (empty(trim($line)) || strpos(trim($line), '--') === 0) { continue; } $sql .= $line; // 检测是否是完整的 SQL 语句 if (preg_match('/;\s*$/', trim($sql))) { try { $db->execute($sql); } catch (\Exception $e) { // 跳过 SET 和某些特殊语句的错误 if (strpos($sql, 'SET FOREIGN_KEY_CHECKS') === false) { Log::warning('执行SQL失败: ' . $e->getMessage()); } } $sql = ''; } } gzclose($gz); } else { $handle = fopen($backupPath, 'r'); if (!$handle) { throw new AdminException('无法打开备份文件'); } $sql = ''; while (!feof($handle)) { $line = fgets($handle); // 跳过注释和空行 if (empty(trim($line)) || strpos(trim($line), '--') === 0) { continue; } $sql .= $line; // 检测是否是完整的 SQL 语句 if (preg_match('/;\s*$/', trim($sql))) { try { $db->execute($sql); } catch (\Exception $e) { if (strpos($sql, 'SET FOREIGN_KEY_CHECKS') === false) { Log::warning('执行SQL失败: ' . $e->getMessage()); } } $sql = ''; } } fclose($handle); } Log::notice(['type' => 'database_restore', 'file' => $backupFileName]); return true; } catch (\Exception $e) { Log::error('数据库恢复失败: ' . $e->getMessage()); throw new AdminException('数据库恢复失败: ' . $e->getMessage()); } } /** * 恢复项目文件备份 * @param string $backupFileName 备份文件名 * @return bool */ public function restoreProject(string $backupFileName): bool { try { $backupPath = app()->getRootPath() . 'backup' . DS . $backupFileName; if (!is_file($backupPath)) { throw new AdminException('项目备份文件不存在'); } /** @var FileService $fileService */ $fileService = app()->make(FileService::class); // 解压zip文件到项目根目录 $result = $fileService->extractFile($backupPath, app()->getRootPath()); if (!$result) { throw new AdminException('项目文件恢复失败'); } Log::notice(['type' => 'project_restore', 'file' => $backupFileName]); return true; } catch (\Exception $e) { Log::error('项目文件恢复失败: ' . $e->getMessage()); throw new AdminException('项目文件恢复失败: ' . $e->getMessage()); } } /** * 完整回退到指定版本 * @param int $logId 升级日志ID * @return array */ public function rollbackToVersion(int $logId): array { try { // 获取升级记录 $logData = $this->dao->getOne(['id' => $logId]); if (!$logData) { throw new AdminException('升级记录不存在'); } $result = [ 'database_restored' => false, 'project_restored' => false, 'version_restored' => false, 'message' => '' ]; // 1. 恢复数据库 if (!empty($logData['data_link'])) { $this->restoreDatabase($logData['data_link']); $result['database_restored'] = true; } // 2. 恢复项目文件 if (!empty($logData['package_link'])) { $this->restoreProject($logData['package_link']); $result['project_restored'] = true; } // 3. 恢复版本号 $versionStr = sprintf( '%s.%s.%s.%s', $logData['first_version'] ?? '5', $logData['second_version'] ?? '5', $logData['third_version'] ?? '0', $logData['fourth_version'] ?? '0' ); $versionCode = (int)($logData['first_version'] . $logData['second_version'] . $logData['third_version']); $versionManager = $this->getVersionManager(); $versionManager->updateVersionFile('CRMEB-BZ v' . $versionStr, $versionCode); $result['version_restored'] = true; $result['message'] = '回退成功'; Log::notice(['type' => 'version_rollback', 'log_id' => $logId, 'version' => $versionStr]); return $result; } catch (\Exception $e) { Log::error('版本回退失败: ' . $e->getMessage()); throw new AdminException('版本回退失败: ' . $e->getMessage()); } } /** * 获取可回退的版本列表 * @return array */ public function getRollbackVersions(): array { $list = $this->dao->getList(['id', 'title', 'first_version', 'second_version', 'third_version', 'fourth_version', 'upgrade_time', 'package_link', 'data_link'], 1, 20); $rootPath = app()->getRootPath(); $rollbackList = []; foreach ($list as $item) { $canRollback = true; $reason = []; // 检查备份文件是否存在 if (empty($item['package_link']) || !is_file($rootPath . 'backup' . DS . $item['package_link'])) { $canRollback = false; $reason[] = '项目备份文件不存在'; } if (empty($item['data_link']) || !is_file($rootPath . 'backup' . DS . $item['data_link'])) { $canRollback = false; $reason[] = '数据库备份文件不存在'; } $rollbackList[] = [ 'id' => $item['id'], 'title' => $item['title'], 'version' => sprintf('v%s.%s.%s', $item['first_version'], $item['second_version'], $item['third_version']), 'upgrade_time' => date('Y-m-d H:i:s', $item['upgrade_time']), 'can_rollback' => $canRollback, 'reason' => implode(', ', $reason) ]; } return $rollbackList; } /** * 检查访问权限 * @param array $data * @return bool */ public function checkAuth(array $data): bool { if (!isset($data['status']) || $data['status'] != 200) { if ($data['status'] == '请输入账号和密码') { $this->getAuth(); } Log::error(['msg' => $data['msg'] ?? '', 'error' => $data]); return false; } return true; } /** * 升级状态 * @return array */ public function getUpgradeStatus(): array { $this->getSign($this->timeStamp); $result = HttpService::getRequest(self::UPGRADE_STATUS_URL, $this->requestData, ['Access-Token: Bearer ' . CacheService::get('upgrade_auth_token')]); if (!$result) { throw new AdminException('升级状态获取失败'); } $data = json_decode($result, true); $this->checkAuth($data); if (!isset($data['data']['auth']) || !$data['data']['auth']) { throw new AdminException('您的域名未授权,请先授权'); } $upgradeData['status'] = $data['data']['status'] ?? 0; $upgradeData['force_reminder'] = $data['data']['force_reminder'] ?? 0; $upgradeData['title'] = $upgradeData['status'] < 1 ? "您已升级至最新版本,无需更新" : "系统有新版本可更新"; return $upgradeData; } /** * 重新执行升级 * @param $type * @return bool * @author wuhaotian * @email 442384644@qq.com * @date 2026/2/27 */ public function reExecute($type) { $token = CacheService::get('upgrade_token'); switch ($type) { case 0: CacheService::set($token . '_check_md5_status', 0, 86400); break; case 1: CacheService::set($token . '_database_backup', 0, 86400); CacheService::set($token . '_project_backup', 0, 86400); break; case 2: CacheService::set($token . '_server_package', 0, 86400); CacheService::set($token . '_client_package', 0, 86400); CacheService::set($token . '_pc_package', 0, 86400); break; case 3: CacheService::set($token . '_coverage_project', 0, 86400); break; default: throw new AdminException('参数错误'); } return true; } /** * 获取下载进度 * 返回下载包、备份、解压的进度状态 * @param $type */ public function getDownloadProgress($type) { // 如果type不在0,1,2,3,4中,则返回错误 if (!in_array($type, [0, 1, 2, 3, 4])) { throw new AdminException('参数错误'); } // 获取升级token $token = CacheService::get('upgrade_token'); if (empty($token)) { return [ 'stage' => 'idle', 'progress' => 0, 'message' => '未开始下载', 'completed' => false, 'can_upgrade' => false ]; } // 阶段1:检测文件是否有改动 if ($type == 0) { // 检查MD5状态,0未检查,1检查中,2检查成功,3忽略 $checkMd5Status = (int)CacheService::get($token . '_check_md5_status', 0); // 检查MD5文件差异数据,空为无差异,否则为差异文件 $checkMd5File = (array)CacheService::get($token . '_check_md5_file', []); // 检测文件是否有改动 if ($checkMd5Status < 2) { // 检测文件状态为0时,开始检测 if ($checkMd5Status == 0) { // 设置检测状态为1,开始检测 CacheService::set($token . '_check_md5_status', 1, 86400); // 触发检测任务 UpgradeJob::dispatch('checkFileMd5', [$token]); } // 检测文件状态为1时,等待检测完成 if (empty($checkMd5File)) { return [ 'stage' => 'loading', 'type' => 0, 'progress' => 0, 'message' => '正在检测文件是否有改动...', 'completed' => false, 'can_upgrade' => false ]; } else { return [ 'stage' => 'error', 'type' => 0, 'progress' => 0, 'message' => '文件有改动,请确认是否继续升级...', 'completed' => false, 'can_upgrade' => false, 'data' => $checkMd5File ]; } } else { return [ 'stage' => 'success', 'type' => 0, 'progress' => 0, 'message' => '文件检测完成', 'completed' => false, 'can_upgrade' => false ]; } } // 阶段2: 备份 if ($type == 1) { // 检测数据库备份状态,0未备份,1备份中,2备份成功,3忽略 $databaseBackup = (int)CacheService::get($token . '_database_backup', 0); // 检测项目备份状态,0未备份,1备份中,2备份成功,3忽略 $projectBackup = (int)CacheService::get($token . '_project_backup', 0); // 检测备份 if ($databaseBackup < 2 || $projectBackup < 2) { // 检测数据库备份状态为0时,开始备份 if ($databaseBackup == 0) { // 设置数据库备份状态为1,开始备份 CacheService::set($token . '_database_backup', 1, 86400); // 触发备份任务 UpgradeJob::dispatch('databaseBackup', [$token]); } // 检测项目备份状态为0时,开始备份 if ($projectBackup == 0) { // 设置项目备份状态为1,开始备份 CacheService::set($token . '_project_backup', 1, 86400); // 触发备份任务 UpgradeJob::dispatch('projectBackup', [$token]); } return [ 'stage' => 'loading', 'type' => 1, 'progress' => 33, 'message' => '正在备份文件和数据库...', 'completed' => false, 'can_upgrade' => false ]; } else { // 检测文件状态为2时,检查文件是否备份成功,备份路径为 backup/600-1.project.zip和 backup/600-1.sql.gz $backupPath = app()->getRootPath() . 'backup' . DS; $versionData = $this->getVersion(); $projectBackupFile = $backupPath . $versionData['version_code'] . '-1.project.zip'; $databaseBackupFile = $backupPath . $versionData['version_code'] . '-1.sql.gz'; if (!is_file($projectBackupFile) || !is_file($databaseBackupFile)) { return [ 'stage' => 'error', 'type' => 1, 'progress' => 33, 'message' => '系统备份失败!', 'completed' => false, 'can_upgrade' => false ]; } else { return [ 'stage' => 'success', 'type' => 1, 'progress' => 33, 'message' => '系统备份成功!', 'completed' => false, 'can_upgrade' => false ]; } } } // 阶段3: 下载更新包 if ($type == 2) { // 检测服务端升级包状态,0未下载,1下载中,2下载成功,3忽略 $serverPackage = (int)CacheService::get($token . '_server_package', 0); // 检测客户端升级包状态,0未下载,1下载中,2下载成功,3忽略 $clientPackage = (int)CacheService::get($token . '_client_package', 0); // 检测PC升级包状态,0未下载,1下载中,2下载成功,3忽略 $pcPackage = (int)CacheService::get($token . '_pc_package', 0); // 检测升级包下载 if ($serverPackage < 2 || $clientPackage < 2 || $pcPackage < 2) { return [ 'stage' => 'loading', 'type' => 2, 'progress' => 66, 'message' => '正在下载升级包...', 'completed' => false, 'can_upgrade' => false, 'detail' => [ 'server' => $serverPackage, 'client' => $clientPackage, 'pc' => $pcPackage ] ]; } else { return [ 'stage' => 'success', 'type' => 2, 'progress' => 66, 'message' => '升级包下载完成!', 'completed' => false, 'can_upgrade' => false ]; } } // 阶段4: 覆盖数据库升级文件,进行升级 if ($type == 3) { $coverageProject = (int)CacheService::get($token . '_coverage_project', 0); if ($coverageProject < 2) { // 解压覆盖数据库更新文件 if ($coverageProject == 0) { // 设置解压覆盖项目状态为1,开始解压覆盖 CacheService::set($token . '_coverage_project', 1, 86400); // 获取下载解压的文件目录 $serverPackagePath = CacheService::get($token . '_server_package_path'); // 判断目录$downloadFilePath目录下面是否存在config/和upgrade/versions/目录,如果存在,将这两个文件夹覆盖到项目根目录 $configPath = $serverPackagePath . DS . 'config'; $versionsPath = $serverPackagePath . DS . 'upgrade' . DS . 'versions'; if (is_dir($configPath) && is_dir($versionsPath)) { /** @var FileService $fileService */ $fileService = app()->make(FileService::class); // 复制config目录 $res = $fileService->copyDir($configPath, app()->getRootPath() . 'config'); // 复制upgrade/versions目录 $res = $res && $fileService->copyDir($versionsPath, app()->getRootPath() . 'upgrade' . DS . 'versions'); // 覆盖成功 if ($res) { // 设置解压覆盖项目状态为2,覆盖成功 CacheService::set($token . '_coverage_project', 2, 86400); return [ 'stage' => 'loading', 'type' => 3, 'progress' => 100, 'message' => '覆盖数据库升级文件完成,开始执行升级...', 'completed' => true, 'can_upgrade' => true, ]; } } } } else { // 执行所有跨版本升级 $data = $this->executeAllCrossVersionUpgrade(); // 执行成功 return [ 'stage' => 'success', 'type' => 3, 'progress' => 100, 'message' => '数据库更新完成', 'completed' => true, 'can_upgrade' => true, 'data' => $data ]; } } // 阶段5: 覆盖项目文件 if ($type == 4) { // 覆盖项目文件 $this->coverageProject($token); return [ 'stage' => 'complete', 'type' => 4, 'progress' => 100, 'message' => '全部更新完成', 'completed' => true, 'can_upgrade' => true, 'routine_upload_data' => CacheService::get('routine_upload_data', []) ]; } } /** * 升级日志 * @return array * @throws \think\db\exception\DataNotFoundException * @throws \think\db\exception\DbException * @throws \think\db\exception\ModelNotFoundException */ public function getUpgradeLogList(): array { [$page, $limit] = $this->getPageValue(); $count = $this->dao->count(); $list = $this->dao->getList(['id', 'title', 'content', 'first_version', 'second_version', 'third_version', 'fourth_version', 'upgrade_time', 'package_link', 'data_link'], $page, $limit); $rootPath = app()->getRootPath(); foreach ($list as &$item) { $item['file_status'] = 0; $item['data_status'] = 0; if ($item['package_link'] && is_file($rootPath . 'backup' . DS . $item['package_link'])) { $item['package_link'] = 'backup/' . $item['package_link']; $item['file_status'] = 1; } if ($item['data_link'] && is_file($rootPath . 'backup' . DS . $item['data_link'])) { $item['data_link'] = 'backup/' . $item['data_link']; $item['data_status'] = 1; } $item['upgrade_time'] = date('Y-m-d H:i:s', $item['upgrade_time']); } return compact('list', 'count'); } /** * 导出 * @param int $id * @param string $type * @return void * @throws \think\db\exception\DataNotFoundException * @throws \think\db\exception\DbException * @throws \think\db\exception\ModelNotFoundException */ public function export(int $id, string $type) { $data = $this->dao->getOne(['id' => $id], 'package_link, data_link'); if (!$data || !$data['package_link']) { throw new AdminException('备份文件不存在'); } $fileName = $type == 'file' ? $data['package_link'] : $data['data_link']; $filePath = app()->getRootPath() . 'backup' . DS . $fileName; if (!is_file($filePath)) { throw new AdminException('备份文件不存在'); } //下载文件 header('Content-Description: File Transfer'); header('Content-Type: application/octet-stream'); header('Content-Disposition: attachment; filename=' . $fileName); header('Content-Transfer-Encoding: binary'); header('Expires: 0'); header('Cache-Control: must-revalidate, post-check=0, pre-check=0'); header('Pragma: public'); header('Content-Length: ' . filesize($filePath)); ob_clean(); flush(); readfile($filePath); //输出文件 } /** * 检查数据库大小 * @return bool */ public function checkDatabaseSize(): bool { if (!$database = Config::get('database.connections.' . Config::get('database.default') . '.database')) { throw new AdminException('数据库信息获取失败'); } $result = Db::query("select concat(round(sum(data_length/1024/1024))) as size from information_schema.tables where table_schema='{$database}';"); if ((int)($result[0]['size'] ?? '') > 500) { throw new AdminException('数据库文件过大, 不能升级'); } return true; } // ==================== 跨版本升级相关方法 ==================== /** * 获取版本管理器实例 * @return \upgrade\VersionManager */ protected function getVersionManager() { // 手动加载 VersionManager 类(避免修改 composer.json) $file = app()->getRootPath() . 'upgrade' . DIRECTORY_SEPARATOR . 'VersionManager.php'; if (!class_exists('\\upgrade\\VersionManager') && file_exists($file)) { require_once $file; } return new \upgrade\VersionManager(); } /** * 获取跨版本升级概览 * @return array */ public function getCrossVersionUpgradeOverview(): array { $versionManager = $this->getVersionManager(); return $versionManager->getUpgradeOverview(); } /** * 获取待升级版本列表 * @return array */ public function getPendingVersions(): array { $versionManager = $this->getVersionManager(); return $versionManager->getPendingVersions(); } /** * 获取所有待执行的升级SQL * @return array */ public function getAllPendingUpgradeSql(): array { $versionManager = $this->getVersionManager(); return $versionManager->getAllPendingUpgradeSql(); } /** * 执行跨版本升级 * @param int $step 当前执行到第几步 * @return array ['success' => bool, 'step' => int, 'total' => int, 'message' => string, 'completed' => bool] */ public function executeCrossVersionUpgrade(int $step = 0): array { $versionManager = $this->getVersionManager(); $allSql = $versionManager->getAllPendingUpgradeSql(); $total = count($allSql); // 检查是否已完成所有升级 if ($step >= $total) { // 获取升级前的版本信息 $beforeVersion = CacheService::get('cross_version_before_version', []); $pendingVersions = $versionManager->getPendingVersions(); // 更新版本文件为最新版本 $latestVersion = $versionManager->getLatestVersion(); if (!empty($latestVersion)) { $versionManager->updateVersionFile($latestVersion['version'], $latestVersion['code']); } // 保存升级日志 $token = CacheService::get('cross_version_upgrade_token', ''); if ($token) { $this->saveCrossVersionUpgradeLog($token, $beforeVersion, $latestVersion, $pendingVersions); } return [ 'success' => true, 'step' => $step, 'total' => $total, 'message' => '升级完成', 'completed' => true, 'current_version' => $latestVersion['version'] ?? '' ]; } // 对于第一步,执行备份 if ($step == 0) { // 记录升级前的版本信息 $beforeVersion = $versionManager->getCurrentVersion(); CacheService::set('cross_version_before_version', $beforeVersion, 86400); $token = md5(time() . uniqid()); CacheService::set('cross_version_upgrade_token', $token, 86400); $backupResult = $this->performBackup($token); if (!$backupResult) { return [ 'success' => false, 'step' => $step, 'total' => $total, 'message' => '备份失败,无法继续升级', 'completed' => false ]; } } // 执行当前步骤的SQL $sqlItem = $allSql[$step]; $result = $versionManager->executeSqlItem($sqlItem); $message = $result['message'] ?? ''; if (isset($sqlItem['version'])) { $message = "[{$sqlItem['version']}] " . $message; } return [ 'success' => $result['success'], 'step' => $step + 1, 'total' => $total, 'message' => $message, 'completed' => false, 'skipped' => $result['skipped'] ?? false, 'sql_info' => [ 'table' => $sqlItem['table'] ?? '', 'field' => $sqlItem['field'] ?? '', 'type' => $sqlItem['type'] ?? 0, 'version' => $sqlItem['version'] ?? '' ] ]; } /** * 一键执行全部跨版本升级 * @return array */ public function executeAllCrossVersionUpgrade(): array { $versionManager = $this->getVersionManager(); $pendingVersions = $versionManager->getPendingVersions(); if (empty($pendingVersions)) { return [ 'success' => true, 'message' => '当前已是最新版本,无需升级', 'executed' => 0, 'skipped' => 0, 'failed' => 0, 'sql_logs' => [] ]; } // 记录升级前的版本信息 $beforeVersion = $versionManager->getCurrentVersion(); // 在执行跨版本升级前进行备份 $token = CacheService::get('upgrade_token'); CacheService::set('cross_version_upgrade_token', $token, 86400); // 初始化进度状态 CacheService::set($token . '_sql_progress', ['current' => 0, 'total' => 0], 86400); CacheService::set($token . '_sql_logs', [], 86400); CacheService::set($token . '_upgrade_complete', 0, 86400); $executed = 0; $skipped = 0; $failed = 0; $failedMessages = []; $migrationResults = []; $sqlLogs = []; // 统计总SQL数量 $totalSql = 0; foreach ($pendingVersions as $version) { $upgradeData = $versionManager->getVersionUpgradeData($version); if (!empty($upgradeData['update_sql'])) { $totalSql += count($upgradeData['update_sql']); } } $this->updateSqlProgress($token, 0, $totalSql); $currentSql = 0; // 遍历每个版本 foreach ($pendingVersions as $version) { $upgradeData = $versionManager->getVersionUpgradeData($version); // 执行SQL升级 if (!empty($upgradeData['update_sql'])) { foreach ($upgradeData['update_sql'] as $sqlItem) { $currentSql++; $result = $versionManager->executeSqlItem($sqlItem); // 记录SQL执行日志 $logEntry = [ 'version' => $version['version'], 'table' => $sqlItem['table'] ?? '-', 'field' => $sqlItem['field'] ?? '', 'type' => $sqlItem['type'] ?? 0, 'status' => $result['success'] ? (isset($result['skipped']) && $result['skipped'] ? 'skipped' : 'success') : 'failed', 'message' => $result['message'] ?? '' ]; $sqlLogs[] = $logEntry; $this->addSqlLog($token, $logEntry); if ($result['success']) { if (isset($result['skipped']) && $result['skipped']) { $skipped++; } else { $executed++; } } else { $failed++; $failedMessages[] = "[{$version['version']}] " . $result['message']; } // 更新进度 $this->updateSqlProgress($token, $currentSql, $totalSql); } } // 执行数据迁移处理器 if (!empty($upgradeData['data_handlers']) && $failed == 0) { /** @var DataMigrationServices $migrationServices */ $migrationServices = app()->make(DataMigrationServices::class); $migrationResult = $migrationServices->executeAllHandlers($upgradeData['data_handlers']); $migrationResults[$version['version']] = $migrationResult; if (!$migrationResult['success']) { $failed++; $failedMessages[] = "[{$version['version']}] 数据迁移失败"; } } // 每个版本升级完成后更新版本文件 $versionManager->updateVersionFile($version['version'], $version['code']); Log::notice(['type' => 'cross_version_upgrade', 'version' => $version['version'], 'code' => $version['code']]); } // 标记升级完成 $this->markUpgradeComplete($token); // 最终获取最新版本信息 $latestVersion = $versionManager->getLatestVersion(); // 升级成功后保存升级日志 if ($failed == 0) { $this->saveCrossVersionUpgradeLog($token, $beforeVersion, $latestVersion, $pendingVersions); } return [ 'success' => $failed == 0, 'message' => $failed == 0 ? '升级成功' : '部分SQL执行失败', 'executed' => $executed, 'skipped' => $skipped, 'failed' => $failed, 'total' => $totalSql, 'failed_messages' => $failedMessages, 'migration_results' => $migrationResults, 'current_version' => $latestVersion['version'] ?? '', 'sql_logs' => $sqlLogs ]; } /** * 执行备份 * @param string $token * @return bool */ protected function performBackup(string $token): bool { try { // 执行数据库备份 CacheService::set($token . '_database_backup', 1, 86400); $this->databaseBackup($token); // 等待数据库备份完成 $maxWait = 30; // 最大等待30秒 $waited = 0; while ($waited < $maxWait) { if (CacheService::get($token . '_database_backup') == 2) { break; } sleep(1); $waited++; } if (CacheService::get($token . '_database_backup') != 2) { throw new AdminException('数据库备份超时'); } // 执行项目备份 CacheService::set($token . '_project_backup', 1, 86400); $this->projectBackup($token); // 等待项目备份完成 $waited = 0; while ($waited < $maxWait) { if (CacheService::get($token . '_project_backup') == 2) { break; } sleep(1); $waited++; } if (CacheService::get($token . '_project_backup') != 2) { throw new AdminException('项目备份超时'); } return true; } catch (\Exception $e) { Log::error('执行备份失败: ' . $e->getMessage()); return false; } } /** * 获取备份状态 * @return array */ public function getBackupStatus(): array { $token = CacheService::get('upgrade_token'); if (empty($token)) { return [ 'database_backup' => 0, 'project_backup' => 0, 'message' => '未开始备份' ]; } $databaseBackup = CacheService::get($token . '_database_backup'); $projectBackup = CacheService::get($token . '_project_backup'); return [ 'database_backup' => $databaseBackup ?? 0, 'project_backup' => $projectBackup ?? 0, 'message' => $this->getBackupMessage($databaseBackup, $projectBackup) ]; } /** * 获取备份状态信息 * @param int $databaseBackup * @param int $projectBackup * @return string */ private function getBackupMessage(int $databaseBackup, int $projectBackup): string { if ($databaseBackup == 2 && $projectBackup == 2) { return '备份完成'; } elseif ($databaseBackup == 1 || $projectBackup == 1) { return '正在备份'; } elseif ($databaseBackup == 0 && $projectBackup == 0) { return '未开始备份'; } else { return '备份异常'; } } /** * 获取升级进度详情 * @return array */ public function getUpgradeProgressDetail(): array { $token = CacheService::get('cross_version_upgrade_token') ?: CacheService::get('upgrade_token'); if (empty($token)) { return [ 'step' => 0, 'progress' => 0, 'step_details' => [ 'database' => '未开始', 'project' => '未开始', 'sql' => '未开始', 'complete' => '未开始', ], 'sql_logs' => [], ]; } // 获取各步骤状态 $databaseBackup = CacheService::get($token . '_database_backup', 0); $projectBackup = CacheService::get($token . '_project_backup', 0); $sqlProgress = CacheService::get($token . '_sql_progress', ['current' => 0, 'total' => 0]); $sqlLogs = CacheService::get($token . '_sql_logs', []); $upgradeComplete = CacheService::get($token . '_upgrade_complete', 0); // 计算当前步骤和进度 $step = 0; $progress = 0; $stepDetails = [ 'database' => '等待中...', 'project' => '等待中...', 'sql' => '等待中...', 'complete' => '等待中...', ]; // 步骤1: 数据库备份 if ($databaseBackup == 1) { $step = 0; $progress = 10; $stepDetails['database'] = '正在备份数据库...'; } elseif ($databaseBackup == 2) { $step = 1; $progress = 25; $stepDetails['database'] = '数据库备份成功 ✓'; } // 步骤2: 项目文件备份 if ($databaseBackup == 2) { if ($projectBackup == 1) { $step = 1; $progress = 35; $stepDetails['project'] = '正在备份项目文件...'; } elseif ($projectBackup == 2) { $step = 2; $progress = 50; $stepDetails['project'] = '项目文件备份成功 ✓'; } } // 步骤3: SQL执行 if ($databaseBackup == 2 && $projectBackup == 2) { $current = $sqlProgress['current'] ?? 0; $total = $sqlProgress['total'] ?? 0; if ($total > 0) { $sqlPercent = ($current / $total) * 100; $progress = 50 + ($sqlPercent * 0.4); // 50-90 $stepDetails['sql'] = "执行中: {$current}/{$total}"; if ($current >= $total) { $step = 3; $progress = 90; $failedCount = count(array_filter($sqlLogs, fn($log) => $log['status'] === 'failed')); $stepDetails['sql'] = $failedCount > 0 ? "SQL执行完成({$failedCount}项失败)" : 'SQL执行完成 ✓'; } } else { $stepDetails['sql'] = '等待执行...'; } } // 步骤4: 完成 if ($upgradeComplete == 2) { $step = 4; $progress = 100; $stepDetails['complete'] = '升级完成 ✓'; } return [ 'step' => $step, 'progress' => (int)$progress, 'step_details' => $stepDetails, 'sql_logs' => array_slice($sqlLogs, -50), // 最多返回50条 ]; } /** * 更新SQL执行进度 * @param string $token * @param int $current * @param int $total * @return void */ protected function updateSqlProgress(string $token, int $current, int $total): void { CacheService::set($token . '_sql_progress', ['current' => $current, 'total' => $total], 86400); } /** * 添加SQL执行日志 * @param string $token * @param array $log * @return void */ protected function addSqlLog(string $token, array $log): void { $logs = CacheService::get($token . '_sql_logs', []); $logs[] = $log; CacheService::set($token . '_sql_logs', $logs, 86400); } /** * 标记升级完成 * @param string $token * @return void */ protected function markUpgradeComplete(string $token): void { CacheService::set($token . '_upgrade_complete', 2, 86400); } /** * 判断是否需要跨版本升级 * @return bool */ public function needCrossVersionUpgrade(): bool { $versionManager = $this->getVersionManager(); return $versionManager->needUpgrade(); } /** * 获取版本差距 * @return int */ public function getVersionGap(): int { $versionManager = $this->getVersionManager(); return $versionManager->getVersionGap(); } /** * 检查跨版本升级可用性 * 检查当前版本是否满足最低版本要求 * @return array */ public function checkCrossVersionUpgradeAvailability(): array { $versionManager = $this->getVersionManager(); return $versionManager->checkUpgradeAvailability(); } /** * 当前版本是否满足最低版本要求 * @return bool */ public function meetsMinVersionRequirement(): bool { $versionManager = $this->getVersionManager(); return $versionManager->meetsMinVersionRequirement(); } /** * 保存跨版本升级日志 * @param string $token 升级token * @param array $beforeVersion 升级前版本信息 * @param array $latestVersion 升级后版本信息 * @param array $pendingVersions 升级的版本列表 * @return bool */ protected function saveCrossVersionUpgradeLog(string $token, array $beforeVersion, array $latestVersion, array $pendingVersions): bool { try { // 解析升级前的版本号 $beforeVersionStr = $beforeVersion['version'] ?? ''; // 解析升级后的版本号 $afterVersionStr = $latestVersion['version'] ?? ''; $afterVersionParts = $this->parseVersionString($afterVersionStr); // 获取备份文件名 $packageLink = CacheService::get($token . '_project_backup_name', ''); $dataLink = CacheService::get($token . '_database_backup_name', ''); $updateContent = CacheService::get('routine_upload_data', [])['desc'] ?? '暂无'; // 保存到数据库 $this->dao->save([ 'title' => '升级 ' . $afterVersionStr . ' 完成', 'content' => '版本升级: ' . $beforeVersionStr . ' -> ' . $afterVersionStr . ';更新内容:' . $updateContent . ';', 'first_version' => $afterVersionParts['first'] ?? '6', 'second_version' => $afterVersionParts['second'] ?? '0', 'third_version' => $afterVersionParts['third'] ?? '0', 'fourth_version' => $afterVersionParts['fourth'] ?? '0', 'upgrade_time' => time(), 'error_data' => '', 'package_link' => $packageLink, 'data_link' => $dataLink ]); Log::notice(['type' => 'cross_version_upgrade_log', 'before' => $beforeVersionStr, 'after' => $afterVersionStr, 'token' => $token]); return true; } catch (\Exception $e) { Log::error('保存跨版本升级日志失败: ' . $e->getMessage()); return false; } } /** * 解析版本字符串 * @param string $versionStr 例如 "CRMEB-BZ v5.6.4" * @return array */ protected function parseVersionString(string $versionStr): array { $result = ['first' => '5', 'second' => '5', 'third' => '0', 'fourth' => '0']; // 匹配版本号 如 v5.6.4 或 5.6.4 if (preg_match('/v?(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?/i', $versionStr, $matches)) { $result['first'] = $matches[1] ?? '5'; $result['second'] = $matches[2] ?? '5'; $result['third'] = $matches[3] ?? '0'; $result['fourth'] = $matches[4] ?? '0'; } return $result; } }