CRMEB/crmeb/app/services/system/UpgradeServices.php
2026-03-23 14:57:47 +08:00

2067 lines
76 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
// +----------------------------------------------------------------------
// | CRMEB [ CRMEB赋能开发者助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed CRMEB并不是自由软件未经许可不能去掉CRMEB相关版权
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
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;
}
}