Files
rhinelab-ui/scripts/archive-service.ps1
T

419 lines
17 KiB
PowerShell
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.
<#
Rhine Lab UI · 本地归档后端(「数据库」)服务管理
这个后端就是 RhineLabUI 的数据层:它索引 E:\归档(当前 4.5 万文件 / 110 GB),
生成 content/archives.json、public/archive-index.json 与 47 份摘要,并在归档变动时
自动重建快照;界面上的 LOCAL ARCHIVE 面板与「文件位置」都靠它。
它只监听 127.0.0.1,且每次启动生成一枚访问令牌写入 .archive-token。
常用:
install 安装开机自启(登录时触发)+ 立即启动 ← 一次性
ensure 没在跑就拉起,在跑就什么都不做 ← 一键脚本会调
status 看状态(任务 / 端口 / 进程 / 快照 / 归档根)
stop 停掉后端
uninstall 取消开机自启并停掉后端
logs 看后端最近的输出
自启的两种装法(install 会自己挑):
· 管理员会话 → 计划任务 WpywArchiveService(登录触发、失败自动重试 3 次)
· 普通会话 → 「启动」文件夹里的快捷方式(不需要提权,效果相同)
实测本机普通会话注册计划任务会被「拒绝访问」,所以默认走「启动」文件夹这条。
为什么用「登录时」而不是「开机 + SYSTEM」:
1) 服务要用 explorer.exe /select 打开资源管理器定位文件(SHOW IN FOLDER)。
以 SYSTEM 跑会落在会话 0,用户屏幕上什么都不会出现 —— 那功能会静默失效。
2) 以当前用户跑,它写出的 archives.json / archive-index.json 归属正常,
用户随后自己跑 npm run build 不会被权限挡住。
3) 开机触发要求把账户口令存进计划任务,本机没有这个必要。
结论:本机单人使用,登录即等于开机可用,且功能无损。窗口由 VBS 隐藏,不弹黑框。
#>
[CmdletBinding()]
param(
[ValidateSet('ensure', 'start', 'stop', 'restart', 'status', 'install', 'uninstall', 'logs', 'run')]
[string]$Action = 'status'
)
$ErrorActionPreference = 'Stop'
$TaskName = 'WpywArchiveService'
$ProjectRoot = Split-Path -Parent $PSScriptRoot
$ConfigPath = Join-Path $ProjectRoot 'archive.config.json'
$ServiceJs = Join-Path $ProjectRoot 'scripts\archive-service.mjs'
$TokenFile = Join-Path $ProjectRoot '.archive-token'
$LogDir = Join-Path $ProjectRoot 'logs'
$LogFile = Join-Path $LogDir 'archive-service.log'
$HiddenVbs = Join-Path $ProjectRoot 'scripts\archive-service-hidden.vbs'
$WScript = Join-Path $env:SystemRoot 'System32\wscript.exe'
$StartupLnk = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::Startup)) '归档后端 (Rhine Lab).lnk'
function Write-Head($text) { Write-Host ''; Write-Host " $text" -ForegroundColor Cyan }
function Write-Item($label, $value) {
Write-Host (' {0,-12} {1}' -f $label, $value)
}
function Write-Ok($text) { Write-Host " $text" -ForegroundColor Green }
function Write-Info2($text) { Write-Host " $text" -ForegroundColor Gray }
function Write-Warn2($text) { Write-Host " $text" -ForegroundColor Yellow }
function Get-NodeExe {
$cmd = Get-Command node.exe -ErrorAction SilentlyContinue
if ($cmd -and $cmd.Source) { return $cmd.Source }
$fallback = 'C:\Program Files\nodejs\node.exe'
if (Test-Path -LiteralPath $fallback) { return $fallback }
throw '找不到 node.exe,请先安装 Node.js。'
}
function Get-ArchiveConfig {
if (-not (Test-Path -LiteralPath $ConfigPath)) { throw "缺少配置文件:$ConfigPath" }
return (Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json)
}
function Get-Port {
$cfg = Get-ArchiveConfig
if ($cfg.port) { return [int]$cfg.port }
return 43117
}
function Get-Token {
if (-not (Test-Path -LiteralPath $TokenFile)) { return '' }
return (Get-Content -LiteralPath $TokenFile -Raw).Trim()
}
function Get-ListenerProcessId {
param([int]$Port)
$conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($conn) { return [int]$conn.OwningProcess }
return 0
}
function Get-CommandLineOf {
param([int]$ProcessId)
$p = Get-CimInstance Win32_Process -Filter "ProcessId=$ProcessId" -ErrorAction SilentlyContinue
if ($p) { return [string]$p.CommandLine }
return ''
}
function Test-BackendListening {
param([int]$Port)
$procId = Get-ListenerProcessId -Port $Port
if ($procId -le 0) { return $false }
return ((Get-CommandLineOf -ProcessId $procId) -like '*archive-service.mjs*')
}
function Get-BackendState {
param([int]$Port)
$state = [ordered]@{
listening = $false; pid = 0; healthy = $false
watching = $false; lastScan = $null; totals = $null; root = $null; lastError = $null; httpError = $null
}
$procId = Get-ListenerProcessId -Port $Port
if ($procId -le 0) { return $state }
if ((Get-CommandLineOf -ProcessId $procId) -notlike '*archive-service.mjs*') { return $state }
$state.listening = $true
$state.pid = $procId
try {
$headers = @{}
$token = Get-Token
if ($token) { $headers['x-archive-token'] = $token }
$r = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/status" -Headers $headers -TimeoutSec 5
$state.healthy = $true
$state.watching = [bool]$r.watching
$state.lastScan = $r.lastScan
$state.totals = $r.totals
$state.root = $r.root
$state.lastError = $r.lastError
} catch {
$state.httpError = $_.Exception.Message
}
return $state
}
function Wait-BackendReady {
param([int]$Port, [int]$TimeoutSec = 150)
$deadline = (Get-Date).AddSeconds($TimeoutSec)
while ((Get-Date) -lt $deadline) {
$s = Get-BackendState -Port $Port
# watching 只有首轮扫描 + 快照写完之后才会为真
if ($s.healthy -and $s.watching) { return $s }
Start-Sleep -Milliseconds 800
}
return (Get-BackendState -Port $Port)
}
function Write-HiddenLauncher {
$node = Get-NodeExe
$ps1 = $PSCommandPath
$lines = @(
"' Generated by archive-service.ps1 - do not edit by hand."
"' Purpose: start the archive backend with a fully hidden window (style 0), so"
"' that logging on does not flash a black console. Also wraps it so the backend"
"' keeps its own log file. NOTE: this file is ASCII-only on purpose - wscript"
"' reads .vbs in the system ANSI code page, so non-ASCII here would garble."
'Set sh = CreateObject("WScript.Shell")'
('sh.CurrentDirectory = "' + $ProjectRoot + '"')
('sh.Run "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File " & Chr(34) & "' + $ps1 + '" & Chr(34) & " -Action run", 0, False')
)
Set-Content -LiteralPath $HiddenVbs -Value $lines -Encoding ASCII
return $HiddenVbs
}
function Test-Elevated {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
return (New-Object Security.Principal.WindowsPrincipal($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Install-ScheduledTaskAutostart {
$vbs = Write-HiddenLauncher
$userId = "$env:USERDOMAIN\$env:USERNAME"
$action = New-ScheduledTaskAction -Execute $WScript -Argument ('"{0}"' -f $vbs) -WorkingDirectory $ProjectRoot
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $userId
$taskPrincipal = New-ScheduledTaskPrincipal -UserId $userId -LogonType Interactive -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-ExecutionTimeLimit ([TimeSpan]::Zero) `
-RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 2) `
-MultipleInstances IgnoreNew -StartWhenAvailable
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
-Principal $taskPrincipal -Settings $settings -Force -ErrorAction Stop `
-Description '莱茵生命档案 UI 的本地归档后端(127.0.0.1:43117)。登录时后台静默启动,随归档变动自动重建索引。' | Out-Null
Write-Ok "已注册计划任务 $TaskName(登录触发 / 后台静默 / 失败自动重试 3 次)"
}
function Install-StartupShortcut {
$vbs = Write-HiddenLauncher
$dir = [Environment]::GetFolderPath([Environment+SpecialFolder]::Startup)
if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
$shell = New-Object -ComObject WScript.Shell
$lnk = $shell.CreateShortcut($StartupLnk)
$lnk.TargetPath = $WScript
$lnk.Arguments = '"' + $vbs + '"'
$lnk.WorkingDirectory = $ProjectRoot
$lnk.WindowStyle = 7
$lnk.Description = '莱茵生命档案 UI 的本地归档后端(登录时后台静默启动)'
$lnk.Save()
[void][Runtime.InteropServices.Marshal]::ReleaseComObject($shell)
Write-Ok "已在「启动」文件夹放置快捷方式:$StartupLnk"
Write-Info2 '登录后自动在后台静默启动,不弹窗口;删掉这个快捷方式即取消自启。'
}
function Install-Autostart {
$vbs = Write-HiddenLauncher
if (Test-Elevated) {
try {
Install-ScheduledTaskAutostart
} catch {
Write-Warn2 "计划任务注册失败($($_.Exception.Message)),改用「启动」文件夹。"
Install-StartupShortcut
}
} else {
Write-Info2 '当前不是管理员会话 —— 计划任务注册会被拒绝,改用「启动」文件夹实现登录自启。'
Write-Info2 '两者效果相同;想要带「失败自动重试」的计划任务版,请用管理员身份再跑一次 -Action install。'
Install-StartupShortcut
}
if (-not (Test-BackendListening -Port (Get-Port))) {
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($task) { Start-ScheduledTask -TaskName $TaskName }
else { Start-Process -FilePath $WScript -ArgumentList ('"{0}"' -f $vbs) -WorkingDirectory $ProjectRoot | Out-Null }
Write-Host ' 已立即启动后端,等待首轮扫描(4.5 万条目,十几秒到一分钟)……'
$state = Wait-BackendReady -Port (Get-Port) -TimeoutSec 300
if ($state.healthy -and $state.watching) {
Write-Ok "后端就绪:端口 $(Get-Port),PID $($state.pid)"
} else {
Write-Warn2 "后端尚未就绪,稍后用 -Action status 复查;日志:$LogFile"
}
}
}
function Uninstall-Autostart {
Stop-Backend
$removed = $false
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($task) {
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
Write-Ok "已删除计划任务 $TaskName"
$removed = $true
}
if (Test-Path -LiteralPath $StartupLnk) {
Remove-Item -LiteralPath $StartupLnk -Force
Write-Ok "已删除「启动」文件夹里的快捷方式"
$removed = $true
}
if (-not $removed) { Write-Warn2 '没有找到任何自启项,跳过。' }
Remove-Item -LiteralPath $HiddenVbs -Force -ErrorAction SilentlyContinue
}
function Start-Backend {
$port = Get-Port
if (Test-BackendListening -Port $port) {
Write-Ok "后端已在运行(端口 $port)"
return $true
}
$other = Get-ListenerProcessId -Port $port
if ($other -gt 0) {
throw "端口 $port 已被 PID $other 占用,而它不是归档后端。请先处理冲突。"
}
$node = Get-NodeExe
if (-not (Test-Path -LiteralPath $ServiceJs)) { throw "找不到后端脚本:$ServiceJs" }
if (-not (Test-Path -LiteralPath $LogDir)) { New-Item -ItemType Directory -Path $LogDir -Force | Out-Null }
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($task) {
Start-ScheduledTask -TaskName $TaskName
} else {
$vbs = Write-HiddenLauncher
Start-Process -FilePath $WScript -ArgumentList ('"{0}"' -f $vbs) -WorkingDirectory $ProjectRoot | Out-Null
}
Write-Host ' 已拉起后端,等待首轮扫描(4.5 万条目大约十几秒)……'
$state = Wait-BackendReady -Port $port -TimeoutSec 180
if ($state.healthy -and $state.watching) {
Write-Ok "后端就绪:端口 $port,PID $($state.pid)"
return $true
}
Write-Warn2 "后端没有在预期时间内就绪。最近日志:$LogFile"
if ($state.httpError) { Write-Warn2 " HTTP:$($state.httpError)" }
if ($state.lastError) { Write-Warn2 " 快照:$($state.lastError)" }
return $false
}
function Stop-Backend {
$port = Get-Port
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($task) { Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue }
$targets = New-Object System.Collections.Generic.List[int]
$procId = Get-ListenerProcessId -Port $port
if ($procId -gt 0 -and (Get-CommandLineOf -ProcessId $procId) -like '*archive-service.mjs*') {
$targets.Add($procId)
}
# 兜底:按命令行找漏网的 node / 宿主
foreach ($p in (Get-CimInstance Win32_Process -ErrorAction SilentlyContinue)) {
$cl = [string]$p.CommandLine
if (-not $cl) { continue }
if ($cl -like '*archive-service.mjs*') { $targets.Add([int]$p.ProcessId) }
elseif ($cl -like '*-Action run*' -and $cl -like '*archive-service.ps1*') { $targets.Add([int]$p.ProcessId) }
elseif ($cl -like '*archive-service-hidden.vbs*') { $targets.Add([int]$p.ProcessId) }
}
$unique = $targets | Sort-Object -Unique
if ($unique.Count -eq 0) {
Write-Warn2 '后端本来就没在运行。'
return
}
foreach ($t in $unique) { Stop-Process -Id $t -Force -ErrorAction SilentlyContinue }
Start-Sleep -Milliseconds 600
if (Get-ListenerProcessId -Port $port) {
Write-Warn2 "停止后端口 $port 仍被占用,请手动检查。"
} else {
Write-Ok "已停止后端(结束 $($unique.Count) 个进程)"
}
}
function Show-Status {
$port = Get-Port
$cfg = Get-ArchiveConfig
$state = Get-BackendState -Port $port
Write-Head '归档后端(数据库)'
Write-Item '归档根' "$($cfg.root)"
Write-Item '端口' "$port(仅本机 127.0.0.1)"
if ($state.listening) {
Write-Item '进程' "PID $($state.pid) 运行中"
} else {
Write-Item '进程' '未运行'
}
if ($state.healthy) {
Write-Item '快照' "已就绪 watching=$($state.watching)"
if ($state.totals) {
$gb = [math]::Round($state.totals.bytes / 1GB, 2)
Write-Item '索引' "$($state.totals.files) 文件 / $($state.totals.dirs) 目录 / $gb GB"
}
Write-Item '上次扫描' "$($state.lastScan)"
if ($state.lastError) { Write-Item '最近错误' "$($state.lastError)" }
} elseif ($state.listening) {
Write-Item '接口' "端口在听但 /status 不通:$($state.httpError)"
Write-Item '提示' '令牌可能是旧的,重启一次后端即可'
} else {
Write-Item '接口' '不可达(后端没在跑)'
}
$tokenAge = '无令牌文件'
if (Test-Path -LiteralPath $TokenFile) {
$t = Get-Item -LiteralPath $TokenFile
$tokenAge = "$($t.LastWriteTime)($(($t.Length)) 字节)"
}
Write-Item '令牌' $tokenAge
Write-Head '开机自启'
$installed = $false
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($task) {
$info = Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue
Write-Item '计划任务' "$TaskName 状态=$($task.State)"
if ($info) { Write-Item '上次结果' "$($info.LastRunTime) code=$($info.LastTaskResult)" }
$installed = $true
}
if (Test-Path -LiteralPath $StartupLnk) {
Write-Item '启动快捷方式' $StartupLnk
$installed = $true
}
if ($installed) {
Write-Ok '已启用:登录时后台静默启动(不弹窗口)'
} else {
Write-Warn2 "未安装。执行 `"$($PSCommandPath) -Action install`" 可装上。"
}
Write-Host ''
}
function Show-Logs {
if (-not (Test-Path -LiteralPath $LogFile)) {
Write-Warn2 "还没有日志:$LogFile"
return
}
Write-Head "后端日志尾部($LogFile)"
Get-Content -LiteralPath $LogFile -Tail 30 -Encoding UTF8 | ForEach-Object { Write-Host " $_" }
Write-Host ''
}
function Invoke-BackendRun {
# 计划任务的真正入口:已经在跑就直接退出,避免重复实例抢端口
$port = Get-Port
if (Test-BackendListening -Port $port) {
Write-Host "归档后端已在运行(端口 $port),本次启动跳过。"
return
}
if (-not (Test-Path -LiteralPath $LogDir)) { New-Item -ItemType Directory -Path $LogDir -Force | Out-Null }
$node = Get-NodeExe
Set-Location -LiteralPath $ProjectRoot
& $node $ServiceJs *> $LogFile
}
switch ($Action) {
'status' { Show-Status }
'logs' { Show-Logs }
'install' { Install-Autostart; Write-Host ''; Show-Status }
'uninstall' { Uninstall-Autostart }
'start' { Start-Backend | Out-Null }
'stop' { Stop-Backend }
'restart' { Stop-Backend; Start-Sleep -Seconds 1; Start-Backend | Out-Null }
'ensure' {
# 明确给出跨进程可依赖的退出码:0 = 就绪,1 = 没能就绪
$ok = Start-Backend
if ($ok) { exit 0 } else { exit 1 }
}
'run' { Invoke-BackendRun }
}