feat: add one-click update feature to admin panel

This commit is contained in:
xboard 2025-02-09 13:43:09 +08:00
parent 1b728fffc7
commit 39456923d3
15 changed files with 660 additions and 100 deletions

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\V2\Admin;
use App\Http\Controllers\Controller;
use App\Services\UpdateService;
use Illuminate\Http\Request;
class UpdateController extends Controller
{
protected $updateService;
public function __construct(UpdateService $updateService)
{
$this->updateService = $updateService;
}
public function checkUpdate()
{
return $this->success($this->updateService->checkForUpdates());
}
public function executeUpdate()
{
$result = $this->updateService->executeUpdate();
return $result['success'] ? $this->success($result) : $this->fail([500, $result['message']]);
}
}

View File

@ -16,6 +16,7 @@ use App\Http\Controllers\V2\Admin\KnowledgeController;
use App\Http\Controllers\V2\Admin\PaymentController; use App\Http\Controllers\V2\Admin\PaymentController;
use App\Http\Controllers\V2\Admin\SystemController; use App\Http\Controllers\V2\Admin\SystemController;
use App\Http\Controllers\V2\Admin\ThemeController; use App\Http\Controllers\V2\Admin\ThemeController;
use App\Http\Controllers\V2\Admin\UpdateController;
use Illuminate\Contracts\Routing\Registrar; use Illuminate\Contracts\Routing\Registrar;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -194,6 +195,14 @@ class AdminRoute
$router->get('/getSystemLog', [SystemController::class, 'getSystemLog']); $router->get('/getSystemLog', [SystemController::class, 'getSystemLog']);
}); });
// Update
$router->group([
'prefix' => 'update'
], function ($router) {
$router->get('/check', [UpdateController::class, 'checkUpdate']);
$router->post('/execute', [UpdateController::class, 'executeUpdate']);
});
// Theme // Theme
$router->group([ $router->group([
'prefix' => 'theme' 'prefix' => 'theme'

View File

@ -0,0 +1,19 @@
<?php
namespace App\Providers;
use App\Services\UpdateService;
use Illuminate\Support\ServiceProvider;
use Laravel\Octane\Events\WorkerStarting;
class OctaneVersionProvider extends ServiceProvider
{
public function boot(): void
{
if ($this->app->bound('octane')) {
$this->app['events']->listen(WorkerStarting::class, function () {
app(UpdateService::class)->updateVersionCache();
});
}
}
}

View File

@ -0,0 +1,439 @@
<?php
namespace App\Services;
use App\Utils\CacheKey;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\File;
class UpdateService
{
const UPDATE_CHECK_INTERVAL = 86400; // 24 hours
const GITHUB_API_URL = 'https://api.github.com/repos/cedar2025/xboard/commits';
const CACHE_UPDATE_INFO = 'UPDATE_INFO';
const CACHE_LAST_CHECK = 'LAST_UPDATE_CHECK';
const CACHE_UPDATE_LOCK = 'UPDATE_LOCK';
const CACHE_VERSION = 'CURRENT_VERSION';
const CACHE_VERSION_DATE = 'CURRENT_VERSION_DATE';
/**
* Get current version from cache or generate new one
*/
public function getCurrentVersion(): string
{
$date = Cache::get(self::CACHE_VERSION_DATE, date('Ymd'));
$hash = Cache::get(self::CACHE_VERSION, $this->getCurrentCommit());
return $date . '-' . $hash;
}
/**
* Update version cache
*/
public function updateVersionCache(): void
{
try {
$result = Process::run('git log -1 --format=%cd:%H --date=format:%Y%m%d');
if ($result->successful()) {
list($date, $hash) = explode(':', trim($result->output()));
Cache::forever(self::CACHE_VERSION_DATE, $date);
Cache::forever(self::CACHE_VERSION, substr($hash, 0, 7));
Log::info('Version cache updated: ' . $date . '-' . substr($hash, 0, 7));
return;
}
} catch (\Exception $e) {
Log::error('Failed to get version with date: ' . $e->getMessage());
}
// Fallback
Cache::forever(self::CACHE_VERSION_DATE, date('Ymd'));
Cache::forever(self::CACHE_VERSION, $this->getCurrentCommit());
Log::info('Version cache updated (fallback): ' . date('Ymd') . '-' . $this->getCurrentCommit());
}
public function checkForUpdates(): array
{
try {
// Get current version commit
$currentCommit = $this->getCurrentCommit();
if ($currentCommit === 'unknown') {
// If unable to get current commit, try to get the first commit
$currentCommit = $this->getFirstCommit();
}
// Get local git logs
$localLogs = $this->getLocalGitLogs();
if (empty($localLogs)) {
Log::error('Failed to get local git logs');
return $this->getCachedUpdateInfo();
}
// Get remote latest commits
$response = Http::withHeaders([
'Accept' => 'application/vnd.github.v3+json',
'User-Agent' => 'XBoard-Update-Checker'
])->get(self::GITHUB_API_URL . '?sha=master&per_page=50');
if ($response->successful()) {
$commits = $response->json();
$latestCommit = $this->formatCommitHash($commits[0]['sha']);
// Find current version position in commit history
$currentIndex = -1;
$updateLogs = [];
$isLocalNewer = false;
// Check if local is newer than remote
foreach ($localLogs as $localCommit) {
$localHash = $this->formatCommitHash($localCommit['hash']);
if ($localHash === $latestCommit) {
break;
}
// If local commit not in remote, local version is newer
$isLocalNewer = true;
$updateLogs[] = [
'version' => $localHash,
'message' => $localCommit['message'],
'author' => $localCommit['author'],
'date' => $localCommit['date'],
'is_local' => true
];
}
if (!$isLocalNewer) {
// If local is not newer, check remote updates
foreach ($commits as $index => $commit) {
$shortSha = $this->formatCommitHash($commit['sha']);
if ($shortSha === $currentCommit) {
$currentIndex = $index;
break;
}
// Collect update logs
$updateLogs[] = [
'version' => $shortSha,
'message' => $commit['commit']['message'],
'author' => $commit['commit']['author']['name'],
'date' => $commit['commit']['author']['date'],
'is_local' => false
];
}
}
$hasUpdate = !$isLocalNewer && $currentIndex !== 0 && $currentIndex !== -1;
$updateInfo = [
'has_update' => $hasUpdate,
'is_local_newer' => $isLocalNewer,
'latest_version' => $isLocalNewer ? $currentCommit : $latestCommit,
'current_version' => $currentCommit,
'update_logs' => $updateLogs,
'download_url' => $commits[0]['html_url'] ?? '',
'published_at' => $commits[0]['commit']['author']['date'] ?? '',
'author' => $commits[0]['commit']['author']['name'] ?? '',
];
// Cache check results
$this->setLastCheckTime();
Cache::put(self::CACHE_UPDATE_INFO, $updateInfo, now()->addHours(24));
return $updateInfo;
}
return $this->getCachedUpdateInfo();
} catch (\Exception $e) {
Log::error('Update check failed: ' . $e->getMessage());
return $this->getCachedUpdateInfo();
}
}
public function executeUpdate(): array
{
// Check for new version first
$updateInfo = $this->checkForUpdates();
if ($updateInfo['is_local_newer']) {
return [
'success' => false,
'message' => __('update.local_newer')
];
}
if (!$updateInfo['has_update']) {
return [
'success' => false,
'message' => __('update.already_latest')
];
}
// Check for update lock
if (Cache::get(self::CACHE_UPDATE_LOCK)) {
return [
'success' => false,
'message' => __('update.process_running')
];
}
try {
// Set update lock
Cache::put(self::CACHE_UPDATE_LOCK, true, now()->addMinutes(30));
// 1. Backup database
$this->backupDatabase();
// 2. Pull latest code
$result = $this->pullLatestCode();
if (!$result['success']) {
throw new \Exception($result['message']);
}
// 3. Run database migrations
$this->runMigrations();
// 4. Clear cache
$this->clearCache();
// 5. Create update flag
$this->createUpdateFlag();
// 6. Restart Octane if running
$this->restartOctane();
// Remove update lock
Cache::forget(self::CACHE_UPDATE_LOCK);
// Format update logs
$logMessages = array_map(function($log) {
return sprintf("- %s (%s): %s",
$log['version'],
date('Y-m-d H:i', strtotime($log['date'])),
$log['message']
);
}, $updateInfo['update_logs']);
return [
'success' => true,
'message' => __('update.success', [
'from' => $updateInfo['current_version'],
'to' => $updateInfo['latest_version']
]),
'version' => $updateInfo['latest_version'],
'update_info' => [
'from_version' => $updateInfo['current_version'],
'to_version' => $updateInfo['latest_version'],
'update_logs' => $logMessages,
'author' => $updateInfo['author'],
'published_at' => $updateInfo['published_at']
]
];
} catch (\Exception $e) {
Log::error('Update execution failed: ' . $e->getMessage());
Cache::forget(self::CACHE_UPDATE_LOCK);
return [
'success' => false,
'message' => __('update.failed', ['error' => $e->getMessage()])
];
}
}
protected function getCurrentCommit(): string
{
try {
$result = Process::run('git rev-parse HEAD');
$fullHash = trim($result->output());
return $fullHash ? $this->formatCommitHash($fullHash) : 'unknown';
} catch (\Exception $e) {
Log::error('Failed to get current commit: ' . $e->getMessage());
return 'unknown';
}
}
protected function getFirstCommit(): string
{
try {
// Get first commit hash
$result = Process::run('git rev-list --max-parents=0 HEAD');
$fullHash = trim($result->output());
return $fullHash ? $this->formatCommitHash($fullHash) : 'unknown';
} catch (\Exception $e) {
Log::error('Failed to get first commit: ' . $e->getMessage());
return 'unknown';
}
}
protected function formatCommitHash(string $hash): string
{
// Use 7 characters for commit hash
return substr($hash, 0, 7);
}
protected function backupDatabase(): void
{
try {
// Use existing backup command
Process::run('php artisan backup:database');
if (!Process::result()->successful()) {
throw new \Exception(__('update.backup_failed', ['error' => Process::result()->errorOutput()]));
}
} catch (\Exception $e) {
Log::error('Database backup failed: ' . $e->getMessage());
throw $e;
}
}
protected function pullLatestCode(): array
{
try {
// Get current project root directory
$basePath = base_path();
// Ensure git configuration is correct
Process::run(sprintf('git config --global --add safe.directory %s', $basePath));
// Pull latest code
Process::run('git fetch origin master');
Process::run('git reset --hard origin/master');
// Update dependencies
Process::run('composer install --no-dev --optimize-autoloader');
// Update version cache after pulling new code
$this->updateVersionCache();
return ['success' => true];
} catch (\Exception $e) {
return [
'success' => false,
'message' => __('update.code_update_failed', ['error' => $e->getMessage()])
];
}
}
protected function runMigrations(): void
{
try {
Process::run('php artisan migrate --force');
} catch (\Exception $e) {
Log::error('Migration failed: ' . $e->getMessage());
throw new \Exception(__('update.migration_failed', ['error' => $e->getMessage()]));
}
}
protected function clearCache(): void
{
try {
$commands = [
'php artisan config:clear',
'php artisan cache:clear',
'php artisan view:clear',
'php artisan route:clear'
];
foreach ($commands as $command) {
Process::run($command);
}
} catch (\Exception $e) {
Log::error('Cache clearing failed: ' . $e->getMessage());
throw new \Exception(__('update.cache_clear_failed', ['error' => $e->getMessage()]));
}
}
protected function createUpdateFlag(): void
{
try {
// Create update flag file for external script to detect and restart container
$flagFile = storage_path('update_pending');
File::put($flagFile, date('Y-m-d H:i:s'));
} catch (\Exception $e) {
Log::error('Failed to create update flag: ' . $e->getMessage());
throw new \Exception(__('update.flag_create_failed', ['error' => $e->getMessage()]));
}
}
protected function restartOctane(): void
{
try {
if (!config('octane.server')) {
return;
}
// Check Octane running status
$statusResult = Process::run('php artisan octane:status');
if (!$statusResult->successful()) {
Log::info('Octane is not running, skipping restart.');
return;
}
$output = $statusResult->output();
if (str_contains($output, 'Octane server is running')) {
Log::info('Restarting Octane server after update...');
// Update version cache before restart
$this->updateVersionCache();
Process::run('php artisan octane:reload');
Log::info('Octane server restarted successfully.');
} else {
Log::info('Octane is not running, skipping restart.');
}
} catch (\Exception $e) {
Log::error('Failed to restart Octane server: ' . $e->getMessage());
// Non-fatal error, don't throw exception
}
}
public function getLastCheckTime()
{
return Cache::get(self::CACHE_LAST_CHECK, null);
}
protected function setLastCheckTime(): void
{
Cache::put(self::CACHE_LAST_CHECK, now()->timestamp, now()->addDays(30));
}
public function getCachedUpdateInfo(): array
{
return Cache::get(self::CACHE_UPDATE_INFO, [
'has_update' => false,
'latest_version' => $this->getCurrentCommit(),
'current_version' => $this->getCurrentCommit(),
'update_logs' => [],
'download_url' => '',
'published_at' => '',
'author' => '',
]);
}
protected function getLocalGitLogs(int $limit = 50): array
{
try {
// 获取本地git log
$result = Process::run(
sprintf('git log -%d --pretty=format:"%%H||%%s||%%an||%%ai"', $limit)
);
if (!$result->successful()) {
return [];
}
$logs = [];
$lines = explode("\n", trim($result->output()));
foreach ($lines as $line) {
$parts = explode('||', $line);
if (count($parts) === 4) {
$logs[] = [
'hash' => $parts[0],
'message' => $parts[1],
'author' => $parts[2],
'date' => $parts[3]
];
}
}
return $logs;
} catch (\Exception $e) {
Log::error('Failed to get local git logs: ' . $e->getMessage());
return [];
}
}
}

View File

@ -177,6 +177,7 @@ return [
App\Providers\SettingServiceProvider::class, App\Providers\SettingServiceProvider::class,
App\Providers\OctaneSchedulerProvider::class, App\Providers\OctaneSchedulerProvider::class,
App\Providers\PluginServiceProvider::class, App\Providers\PluginServiceProvider::class,
App\Providers\OctaneVersionProvider::class,
], ],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -874,6 +874,17 @@ window.XBOARD_TRANSLATIONS['en-US'] = {
"nextPage": "Next page", "nextPage": "Next page",
"lastPage": "Go to last page" "lastPage": "Go to last page"
} }
},
"update": {
"title": "System Update",
"newVersion": "New Version Available",
"currentVersion": "Current Version",
"latestVersion": "Latest Version",
"updateLater": "Update Later",
"updateNow": "Update Now",
"updating": "Updating...",
"updateSuccess": "Update successful, system will restart shortly",
"updateFailed": "Update failed, please try again later"
} }
}, },
"dashboard": { "dashboard": {

View File

@ -870,6 +870,17 @@ window.XBOARD_TRANSLATIONS['ko-KR'] = {
"nextPage": "다음 페이지", "nextPage": "다음 페이지",
"lastPage": "마지막 페이지로 이동" "lastPage": "마지막 페이지로 이동"
} }
},
"update": {
"title": "시스템 업데이트",
"newVersion": "새 버전 발견",
"currentVersion": "현재 버전",
"latestVersion": "최신 버전",
"updateLater": "나중에 업데이트",
"updateNow": "지금 업데이트",
"updating": "업데이트 중...",
"updateSuccess": "업데이트 성공, 시스템이 곧 재시작됩니다",
"updateFailed": "업데이트 실패, 나중에 다시 시도해주세요"
} }
}, },
"dashboard": { "dashboard": {

View File

@ -879,6 +879,17 @@ window.XBOARD_TRANSLATIONS['zh-CN'] = {
"nextPage": "下一页", "nextPage": "下一页",
"lastPage": "跳转到最后一页" "lastPage": "跳转到最后一页"
} }
},
"update": {
"title": "系统更新",
"newVersion": "发现新版本",
"currentVersion": "当前版本",
"latestVersion": "最新版本",
"updateLater": "稍后更新",
"updateNow": "立即更新",
"updating": "更新中...",
"updateSuccess": "更新成功,系统将在稍后自动重启",
"updateFailed": "更新失败,请稍后重试"
} }
}, },
"dashboard": { "dashboard": {

View File

@ -119,5 +119,15 @@
"Monthly": "Monthly", "Monthly": "Monthly",
"Never": "Never", "Never": "Never",
"First Day of Year": "First Day of Year", "First Day of Year": "First Day of Year",
"Yearly": "Yearly" "Yearly": "Yearly",
"update.local_newer": "Current version is newer than remote version, please commit your changes first",
"update.already_latest": "Already on the latest version",
"update.process_running": "Update process is already running",
"update.success": "Update successful, from :from to :to, system will restart automatically later",
"update.failed": "Update failed: :error",
"update.backup_failed": "Database backup failed: :error",
"update.code_update_failed": "Code update failed: :error",
"update.migration_failed": "Database migration failed: :error",
"update.cache_clear_failed": "Cache clearing failed: :error",
"update.flag_create_failed": "Failed to create update flag: :error"
} }

View File

@ -119,5 +119,15 @@
"Monthly": "按月", "Monthly": "按月",
"Never": "不重置", "Never": "不重置",
"First Day of Year": "每年1月1日", "First Day of Year": "每年1月1日",
"Yearly": "按年" "Yearly": "按年",
"update.local_newer": "当前版本比远程版本更新,请先提交您的更改",
"update.already_latest": "当前已经是最新版本",
"update.process_running": "更新进程正在运行中",
"update.success": "更新成功,从 :from 更新到 :to, 系统将在稍后自动重启",
"update.failed": "更新失败: :error",
"update.backup_failed": "数据库备份失败: :error",
"update.code_update_failed": "代码更新失败: :error",
"update.migration_failed": "数据库迁移失败: :error",
"update.cache_clear_failed": "缓存清理失败: :error",
"update.flag_create_failed": "创建更新标记失败: :error"
} }

View File

@ -119,5 +119,15 @@
"Monthly": "按月", "Monthly": "按月",
"Never": "不重置", "Never": "不重置",
"First Day of Year": "每年1月1日", "First Day of Year": "每年1月1日",
"Yearly": "按年" "Yearly": "按年",
"update.local_newer": "當前版本比遠程版本更新,請先提交您的更改",
"update.already_latest": "當前已經是最新版本",
"update.process_running": "更新進程正在運行中",
"update.success": "更新成功,從 :from 更新到 :to, 系統將在稍後自動重啟",
"update.failed": "更新失敗: :error",
"update.backup_failed": "數據庫備份失敗: :error",
"update.code_update_failed": "代碼更新失敗: :error",
"update.migration_failed": "數據庫遷移失敗: :error",
"update.cache_clear_failed": "緩存清理失敗: :error",
"update.flag_create_failed": "創建更新標記失敗: :error"
} }

View File

@ -1,6 +1,7 @@
<?php <?php
use App\Services\ThemeService; use App\Services\ThemeService;
use App\Services\UpdateService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@ -57,7 +58,7 @@ Route::get('/', function (Request $request) {
$renderParams = [ $renderParams = [
'title' => admin_setting('app_name', 'Xboard'), 'title' => admin_setting('app_name', 'Xboard'),
'theme' => $theme, 'theme' => $theme,
'version' => config('app.version'), 'version' => app(UpdateService::class)->getCurrentVersion(),
'description' => admin_setting('app_description', 'Xboard is best'), 'description' => admin_setting('app_description', 'Xboard is best'),
'logo' => admin_setting('logo'), 'logo' => admin_setting('logo'),
'theme_config' => $themeService->getConfig($theme) 'theme_config' => $themeService->getConfig($theme)
@ -80,7 +81,7 @@ Route::get('/' . admin_setting('secure_path', admin_setting('frontend_admin_path
'theme_header' => admin_setting('frontend_theme_header', 'dark'), 'theme_header' => admin_setting('frontend_theme_header', 'dark'),
'theme_color' => admin_setting('frontend_theme_color', 'default'), 'theme_color' => admin_setting('frontend_theme_color', 'default'),
'background_url' => admin_setting('frontend_background_url'), 'background_url' => admin_setting('frontend_background_url'),
'version' => config('app.version'), 'version' => app(UpdateService::class)->getCurrentVersion(),
'logo' => admin_setting('logo'), 'logo' => admin_setting('logo'),
'secure_path' => admin_setting('secure_path', admin_setting('frontend_admin_path', hash('crc32b', config('app.key')))) 'secure_path' => admin_setting('secure_path', admin_setting('frontend_admin_path', hash('crc32b', config('app.key'))))
]); ]);