#Requires -Version 5.1 <# .SYNOPSIS 独立备份页面:点选磁盘/文件夹,不手输路径。只监听 127.0.0.1。 #> $ErrorActionPreference = "Stop" $RootDir = Split-Path -Parent $MyInvocation.MyCommand.Path $HtmlFile = Join-Path $RootDir "backup-ui.html" $BackupScript = Join-Path $RootDir "backup-db.ps1" $Port = 18765 $Url = $null $listener = $null . (Join-Path $RootDir "db-config.ps1") if (-not (Test-Path $HtmlFile)) { throw "找不到 backup-ui.html" } if (-not (Test-Path $BackupScript)) { throw "找不到 backup-db.ps1" } Add-Type -AssemblyName System.Windows.Forms if (-not ("MesFolderPicker" -as [type])) { Add-Type -ReferencedAssemblies System.Windows.Forms,System.Drawing -TypeDefinition @" using System; using System.Runtime.InteropServices; using System.Threading; using System.Windows.Forms; public static class MesFolderPicker { [DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll")] static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); [DllImport("user32.dll")] static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr processId); [DllImport("kernel32.dll")] static extern uint GetCurrentThreadId(); [DllImport("user32.dll")] static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); [DllImport("user32.dll")] static extern bool BringWindowToTop(IntPtr hWnd); public static string Pick(string description) { string selected = null; Thread t = new Thread(delegate() { Form form = new Form(); form.Text = "MES Backup - Select Folder"; form.TopMost = true; form.ShowInTaskbar = true; form.StartPosition = FormStartPosition.CenterScreen; form.Width = 420; form.Height = 90; form.FormBorderStyle = FormBorderStyle.FixedToolWindow; form.MinimizeBox = false; form.MaximizeBox = false; form.Show(); form.WindowState = FormWindowState.Normal; form.Activate(); ForceForeground(form.Handle); Application.DoEvents(); FolderBrowserDialog dlg = new FolderBrowserDialog(); dlg.Description = description; dlg.ShowNewFolderButton = true; dlg.RootFolder = Environment.SpecialFolder.MyComputer; DialogResult r = dlg.ShowDialog(form); if (r == DialogResult.OK) { selected = dlg.SelectedPath; } form.Close(); form.Dispose(); }); t.SetApartmentState(ApartmentState.STA); t.Start(); t.Join(); return selected; } static void ForceForeground(IntPtr hwnd) { IntPtr fg = GetForegroundWindow(); uint fgThread = GetWindowThreadProcessId(fg, IntPtr.Zero); uint thisThread = GetCurrentThreadId(); if (fgThread != thisThread) { AttachThreadInput(thisThread, fgThread, true); } ShowWindow(hwnd, 9); BringWindowToTop(hwnd); SetForegroundWindow(hwnd); if (fgThread != thisThread) { AttachThreadInput(thisThread, fgThread, false); } } } "@ } $State = [hashtable]::Synchronized(@{ Running = $false Log = New-Object System.Collections.ArrayList Result = "" Error = "" Path = "" StartedAt = "" EndedAt = "" Proc = $null OutFile = "" DbBytes = 0 }) function Add-UiLog { param([string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return } [void]$State.Log.Add($Text) if ($State.Log.Count -gt 800) { $State.Log.RemoveRange(0, 200) } } function Get-SafePath { param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { throw "请先选择保存位置" } $p = $Path.Trim().Trim('"') if ($p -notmatch '^[A-Za-z]:\\') { throw "只允许本机磁盘路径" } $full = [System.IO.Path]::GetFullPath($p) if ($full -match '^[A-Za-z]:\\?$') { return ($full.Substring(0, 1).ToUpper() + ":\") } return $full.TrimEnd("\") } $script:MysqlToolCache = @{} function Find-MysqlTool { param([string]$Name) if ($script:MysqlToolCache.ContainsKey($Name)) { return $script:MysqlToolCache[$Name] } $cmd = Get-Command $Name -ErrorAction SilentlyContinue if ($cmd -and $cmd.Source) { $script:MysqlToolCache[$Name] = $cmd.Source return $cmd.Source } $candidates = New-Object System.Collections.Generic.List[string] foreach ($root in @( "C:\Program Files\MySQL", "C:\Program Files (x86)\MySQL", "C:\mysql", "D:\MySQL", "E:\MySQL", "C:\Program Files\MariaDB" )) { if (-not (Test-Path $root)) { continue } Get-ChildItem -Path $root -Directory -ErrorAction SilentlyContinue | ForEach-Object { $direct = Join-Path $_.FullName ("bin\{0}.exe" -f $Name) $candidates.Add($direct) Get-ChildItem -Path $_.FullName -Directory -ErrorAction SilentlyContinue | ForEach-Object { $candidates.Add((Join-Path $_.FullName ("bin\{0}.exe" -f $Name))) } } } foreach ($p in $candidates) { if (Test-Path -LiteralPath $p) { $script:MysqlToolCache[$Name] = $p return $p } } return $null } function Get-DbSizeBytes { try { $cfg = Read-MesDbConfig $mysql = Find-MysqlTool "mysql" if (-not $mysql) { return 0 } $cnf = Join-Path $env:TEMP ("mes_ui_size_{0}.cnf" -f $PID) Write-MesMysqlCnf -Config $cfg -CnfPath $cnf $sql = "SELECT IFNULL(SUM(data_length+index_length),0) FROM information_schema.tables WHERE table_schema='$($cfg.database)'" $out = & $mysql "--defaults-extra-file=$cnf" "--connect-timeout=3" -N -s -e $sql 2>$null Remove-Item $cnf -Force -ErrorAction SilentlyContinue $n = 0 [void][int64]::TryParse(($out | Out-String).Trim(), [ref]$n) return $n } catch { return 0 } } function Test-MesMysqlConn { param($Config) $cfg = ConvertTo-MesDbConfig -InputObject $Config $mysql = Find-MysqlTool "mysql" if (-not $mysql) { throw "找不到 mysql.exe,请安装 MySQL 客户端" } $cnf = Join-Path $env:TEMP ("mes_ui_test_{0}.cnf" -f $PID) Write-MesMysqlCnf -Config $cfg -CnfPath $cnf try { $out = & $mysql "--defaults-extra-file=$cnf" "--connect-timeout=5" -N -s -e "SELECT 1" 2>&1 if ($LASTEXITCODE -ne 0) { throw ("连接失败: {0}" -f ($out | Out-String).Trim()) } } finally { Remove-Item $cnf -Force -ErrorAction SilentlyContinue } return $cfg } function Get-DriveList { $list = New-Object System.Collections.Generic.List[object] foreach ($root in [System.IO.Directory]::GetLogicalDrives()) { if ($root -match '^[AB]:\\$') { continue } $letter = $root.Substring(0, 2).ToUpper() $list.Add([ordered]@{ letter = $letter path = $root name = $letter type = "磁盘" removable = $false ready = $true sizeGB = 0 freeGB = 0 freeBytes = 0 }) } return $list } function Get-FolderList { param([string]$Path) $full = Get-SafePath $Path if (-not (Test-Path -LiteralPath $full)) { throw "路径不存在,请确认已插入磁盘" } $parent = [System.IO.Path]::GetDirectoryName($full) $folders = New-Object System.Collections.Generic.List[object] Get-ChildItem -LiteralPath $full -Directory -Force -ErrorAction SilentlyContinue | Select-Object -First 80 | ForEach-Object { $folders.Add([ordered]@{ name = $_.Name; path = $_.FullName }) } $pathOut = $full if (-not $pathOut.EndsWith("\")) { $pathOut += "\" } return [ordered]@{ path = $pathOut parent = $parent folders = $folders } } function Get-FreeBytes { param([string]$Path) try { $full = Get-SafePath $Path $root = [System.IO.Path]::GetPathRoot($full) $di = New-Object System.IO.DriveInfo ($root) if ($di.IsReady) { return [int64]$di.AvailableFreeSpace } } catch { } return 0 } function Show-FolderPicker { return [MesFolderPicker]::Pick("选择备份文件保存位置(可选手动插入的 U 盘或移动硬盘)") } function Get-WrittenBackup { $info = [ordered]@{ bytes = 0; name = ""; doneBytes = 0 } $dir = $State.Path if (-not $dir -or -not (Test-Path -LiteralPath $dir)) { return $info } $files = @(Get-ChildItem -LiteralPath $dir -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -like "mes_cloud_*.sql.gz*" } | Sort-Object LastWriteTime -Descending) if ($files.Count -gt 0) { $final = @($files | Where-Object { $_.Name -notlike "*.tmp" }) | Select-Object -First 1 $pick = if ($final) { $final } else { $files[0] } $info.name = $pick.Name $info.bytes = [int64]$pick.Length if ($pick.Name -notlike "*.tmp") { $info.doneBytes = [int64]$pick.Length } } return $info } function Read-JsonBody { param($Request) $reader = New-Object System.IO.StreamReader($Request.InputStream, [System.Text.Encoding]::UTF8) try { $raw = $reader.ReadToEnd() } finally { $reader.Dispose() } if ([string]::IsNullOrWhiteSpace($raw)) { return $null } return $raw | ConvertFrom-Json } function Send-Bytes { param($Response, [byte[]]$Bytes, [string]$ContentType, [int]$Status = 200) $Response.StatusCode = $Status $Response.ContentType = $ContentType $Response.Headers["Cache-Control"] = "no-store" $Response.ContentLength64 = $Bytes.Length $Response.OutputStream.Write($Bytes, 0, $Bytes.Length) $Response.OutputStream.Close() } function Send-Json { param($Response, $Object, [int]$Status = 200) $json = $Object | ConvertTo-Json -Depth 8 -Compress $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) Send-Bytes -Response $Response -Bytes $bytes -ContentType "application/json; charset=utf-8" -Status $Status } function Send-Html { param($Response) $bytes = [System.IO.File]::ReadAllBytes($HtmlFile) Send-Bytes -Response $Response -Bytes $bytes -ContentType "text/html; charset=utf-8" } function Start-BackupJob { param([string]$TargetDir, [int]$KeepDays) if ($State.Running) { throw "已有备份正在进行" } $dir = Get-SafePath $TargetDir $free = Get-FreeBytes -Path $dir $dbBytes = Get-DbSizeBytes $State.DbBytes = $dbBytes # gzip 后通常小于库文件,但 SQL 文本可能接近库大小;预留 1.2 倍库容量 if ($dbBytes -gt 0 -and $free -gt 0 -and $free -lt [int64]($dbBytes * 1.2)) { $needGB = [math]::Round($dbBytes * 1.2 / 1GB, 2) $freeGB = [math]::Round($free / 1GB, 2) throw ("目标盘剩余空间不足:约需 {0} GB,当前剩余 {1} GB。请换更大的 U 盘/硬盘。" -f $needGB, $freeGB) } if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $outFile = Join-Path $env:TEMP ("mes_backup_ui_{0}.log" -f (Get-Date -Format "yyyyMMddHHmmss")) $errFile = $outFile + ".err" if (Test-Path $outFile) { Remove-Item $outFile -Force } if (Test-Path $errFile) { Remove-Item $errFile -Force } $arg = '-NoProfile -ExecutionPolicy Bypass -File "{0}" -BackupDir "{1}" -KeepDaysParam {2}' -f $BackupScript, $dir, $KeepDays $proc = Start-Process -FilePath "powershell.exe" -ArgumentList $arg -PassThru -WindowStyle Hidden ` -RedirectStandardOutput $outFile -RedirectStandardError $errFile $State.Running = $true $State.Result = "" $State.Error = "" $State.Path = $dir $State.StartedAt = (Get-Date -Format "yyyy-MM-dd HH:mm:ss") $State.EndedAt = "" $State.Proc = $proc $State.OutFile = $outFile $State.Log.Clear() Add-UiLog ("开始备份到: {0}" -f $dir) if ($dbBytes -gt 0) { Add-UiLog ("库数据约 {0} GB,采用边导出边压缩,请耐心等待,勿拔盘" -f ([math]::Round($dbBytes / 1GB, 2))) } } function Read-LogFiles { $lines = New-Object System.Collections.ArrayList foreach ($f in @($State.OutFile, ($State.OutFile + ".err"))) { if (-not $f -or -not (Test-Path $f)) { continue } $text = $null foreach ($encName in @("UTF8", "Default")) { try { $enc = [System.Text.Encoding]::$encName $text = [System.IO.File]::ReadAllText($f, $enc) if ($text -and $text.IndexOf([char]0xFFFD) -lt 0) { break } } catch { } } if ($text) { foreach ($line in ($text -split "`r?`n")) { if ($line.Trim() -ne "") { [void]$lines.Add($line) } } } } return $lines } function Test-BackupFileOk { $dir = $State.Path if (-not $dir -or -not (Test-Path -LiteralPath $dir)) { return $false } $started = $null try { if ($State.StartedAt) { $started = [datetime]::Parse($State.StartedAt) } } catch { } $gz = Get-ChildItem -LiteralPath $dir -File -Filter "mes_cloud_*.sql.gz" -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($null -eq $gz -or $gz.Length -le 100) { return $false } if ($started -and $gz.LastWriteTime -lt $started.AddMinutes(-2)) { return $false } return $true } function Update-BackupState { if (-not $State.Running) { return } $proc = $State.Proc $lines = Read-LogFiles if ($lines.Count -gt 0) { $State.Log.Clear() foreach ($line in $lines) { [void]$State.Log.Add($line) } } if ($null -eq $proc) { return } try { $proc.Refresh() } catch { } try { $exited = $proc.HasExited } catch { $exited = $true } if (-not $exited) { return } try { $proc.WaitForExit(1000) } catch { } $code = $null try { $code = $proc.ExitCode } catch { $code = $null } $State.Running = $false $State.EndedAt = (Get-Date -Format "yyyy-MM-dd HH:mm:ss") $State.Proc = $null $fileOk = Test-BackupFileOk # Start-Process 有时读不到 ExitCode(界面显示「退出码」后面空白);有新生成的 .gz 即视为成功 $success = ($code -eq 0) -or $fileOk if ($success) { $State.Result = "ok" $State.Error = "" Add-UiLog "备份完成" } else { $State.Result = "error" $codeText = if ($null -eq $code) { "未知" } else { [string]$code } $State.Error = "备份失败,退出码 $codeText" Add-UiLog $State.Error } } $listener = $null $started = $false $lastError = $null foreach ($tryPort in 18765..18785) { $tryUrl = "http://127.0.0.1:{0}/" -f $tryPort $tryListener = New-Object System.Net.HttpListener try { $tryListener.Prefixes.Add($tryUrl) $tryListener.Start() $listener = $tryListener $Port = $tryPort $Url = $tryUrl $started = $true break } catch { $lastError = $_.Exception.Message try { $tryListener.Close() } catch { } } } if (-not $started) { throw ("无法启动本机页面,18765-18785 端口都被占用。请先关掉之前打开的备份窗口后再试。详情: {0}" -f $lastError) } Write-Host "" Write-Host ("数据库备份页面已启动(仅本机): {0}" -f $Url) if ($Port -ne 18765) { Write-Host ("原端口 18765 已被占用,已自动改用 {0}。请先关掉旧的备份窗口,避免开两个。" -f $Port) } Write-Host "请在网页点「浏览文件夹」选择 U 盘/硬盘目录,不要手输路径。" Write-Host "关闭本窗口即停止页面。" Write-Host "" Start-Process $Url try { while ($listener.IsListening) { $ctx = $null try { $ctx = $listener.GetContext() $req = $ctx.Request $res = $ctx.Response $path = $req.Url.AbsolutePath.TrimEnd("/") if ([string]::IsNullOrEmpty($path)) { $path = "/" } if ($req.HttpMethod -eq "OPTIONS") { $res.StatusCode = 204 $res.AddHeader("Access-Control-Allow-Origin", "*") $res.AddHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS") $res.AddHeader("Access-Control-Allow-Headers", "Content-Type") $res.ContentLength64 = 0 $res.Close() continue } if ($req.HttpMethod -eq "GET" -and ($path -eq "/" -or $path -eq "/index.html")) { Send-Html -Response $res continue } if ($req.HttpMethod -eq "GET" -and $path -eq "/api/drives") { Send-Json -Response $res -Object ([ordered]@{ ok = $true drives = @(Get-DriveList) }) continue } if ($req.HttpMethod -eq "GET" -and $path -eq "/api/dbsize") { Send-Json -Response $res -Object ([ordered]@{ ok = $true dbBytes = [int64](Get-DbSizeBytes) }) continue } if ($req.HttpMethod -eq "GET" -and $path -eq "/api/folders") { $folderPath = "" $query = $req.Url.Query.TrimStart("?") if ($query) { foreach ($pair in $query.Split([char]38)) { $kv = $pair.Split("=", 2) if ($kv.Count -ge 2 -and [uri]::UnescapeDataString($kv[0]) -eq "path") { $folderPath = [uri]::UnescapeDataString(($kv[1] -replace "\+", " ")) } } } Send-Json -Response $res -Object ([ordered]@{ ok = $true; data = (Get-FolderList -Path $folderPath) }) continue } if ($req.HttpMethod -eq "POST" -and $path -eq "/api/browse") { $picked = Show-FolderPicker if ([string]::IsNullOrWhiteSpace($picked)) { Send-Json -Response $res -Object ([ordered]@{ ok = $true; cancelled = $true }) } else { Send-Json -Response $res -Object ([ordered]@{ ok = $true cancelled = $false path = $picked freeBytes = [int64](Get-FreeBytes -Path $picked) }) } continue } if ($req.HttpMethod -eq "GET" -and $path -eq "/api/status") { Update-BackupState $written = Get-WrittenBackup Send-Json -Response $res -Object ([ordered]@{ ok = $true running = [bool]$State.Running result = $State.Result error = $State.Error path = $State.Path startedAt = $State.StartedAt endedAt = $State.EndedAt log = @($State.Log) dbBytes = [int64]$State.DbBytes writtenBytes = [int64]$written.bytes fileName = $written.name }) continue } if ($req.HttpMethod -eq "GET" -and $path -eq "/api/config") { try { $cfg = Read-MesDbConfig } catch { $cfg = Get-DefaultMesDbConfig } Send-Json -Response $res -Object ([ordered]@{ ok = $true engine = "mysql" host = [string]$cfg.host port = [int]$cfg.port database = [string]$cfg.database user = [string]$cfg.user password = [string]$cfg.password }) continue } if ($req.HttpMethod -eq "POST" -and $path -eq "/api/config") { $body = Read-JsonBody -Request $req $cfg = Save-MesDbConfig -Config $body Send-Json -Response $res -Object ([ordered]@{ ok = $true message = "已保存 MySQL 配置" host = [string]$cfg.host port = [int]$cfg.port database = [string]$cfg.database user = [string]$cfg.user }) continue } if (($req.HttpMethod -eq "GET" -or $req.HttpMethod -eq "POST") -and $path -eq "/api/test") { $body = $null if ($req.HttpMethod -eq "POST") { $body = Read-JsonBody -Request $req } if ($null -eq $body) { $q = $req.Url.Query.TrimStart("?") $map = @{} if ($q) { foreach ($pair in $q.Split([char]38)) { $kv = $pair.Split("=", 2) if ($kv.Count -ge 2) { $map[[uri]::UnescapeDataString($kv[0])] = [uri]::UnescapeDataString(($kv[1] -replace "\+", " ")) } } } $body = [pscustomobject]@{ engine = "mysql" host = $map["host"] port = $map["port"] database = $map["database"] user = $map["user"] password = $map["password"] } } $cfg = Test-MesMysqlConn -Config $body Send-Json -Response $res -Object ([ordered]@{ ok = $true message = ("MySQL 连接成功: {0}:{1} / {2}" -f $cfg.host, $cfg.port, $cfg.database) }) continue } if ($req.HttpMethod -eq "POST" -and $path -eq "/api/backup") { $body = Read-JsonBody -Request $req if ($body -and $body.host) { Save-MesDbConfig -Config $body | Out-Null } $keep = 0 if ($body -and $null -ne $body.keepDays) { $keep = [int]$body.keepDays } Start-BackupJob -TargetDir $body.path -KeepDays $keep Send-Json -Response $res -Object ([ordered]@{ ok = $true; message = "已开始备份" }) continue } Send-Json -Response $res -Object ([ordered]@{ ok = $false; message = "未找到接口" }) -Status 404 } catch { if ($null -ne $ctx) { try { Send-Json -Response $ctx.Response -Object ([ordered]@{ ok = $false; message = $_.Exception.Message }) -Status 500 } catch { } } } } } finally { if ($listener) { try { $listener.Stop() } catch { } try { $listener.Close() } catch { } } }