From 266211e9e6f11608ec839124e114aac267804fa4 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Mon, 3 Aug 2026 21:05:06 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9AWindows=20?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E8=84=9A=E6=9C=AC=E7=AB=AF=E5=8F=A3=E5=86=B2?= =?UTF-8?q?=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- start_windows.ps1 | 197 +++++++++++++++++++++++++++++++++------------- 1 file changed, 142 insertions(+), 55 deletions(-) diff --git a/start_windows.ps1 b/start_windows.ps1 index dbdff43..116304e 100644 --- a/start_windows.ps1 +++ b/start_windows.ps1 @@ -1,8 +1,9 @@ -$ErrorActionPreference = "Stop" +$ErrorActionPreference = "Stop" [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 function Write-Section { param([string]$Text) + Write-Host "" Write-Host "===============================================" Write-Host $Text @@ -10,7 +11,11 @@ function Write-Section { } function Fail-WithHelp { - param([string]$Message, [string]$Fix) + param( + [string]$Message, + [string]$Fix + ) + Write-Host "" Write-Host "错误:$Message" -ForegroundColor Red Write-Host "解决办法:$Fix" -ForegroundColor Yellow @@ -19,11 +24,12 @@ function Fail-WithHelp { function Test-CommandExists { param([string]$CommandName) + return $null -ne (Get-Command $CommandName -ErrorAction SilentlyContinue) } function Get-JavaMajorVersion { - $versionText = (& java -version 2>&1 | Out-String) + $versionText = (& cmd.exe /d /c "java -version 2>&1" | Out-String) if ($versionText -match 'version "(\d+)') { return [int]$Matches[1] } @@ -35,20 +41,63 @@ function Get-JavaMajorVersion { function Test-PortOpen { param([int]$Port) + + $client = New-Object System.Net.Sockets.TcpClient try { - $client = New-Object System.Net.Sockets.TcpClient $async = $client.BeginConnect("127.0.0.1", $Port, $null, $null) - $ok = $async.AsyncWaitHandle.WaitOne(1000, $false) - if ($ok) { - $client.EndConnect($async) + $connected = $async.AsyncWaitHandle.WaitOne(1000, $false) + if (-not $connected) { + return $false } - $client.Close() - return $ok + $client.EndConnect($async) + return $true } catch { return $false + } finally { + $client.Close() } } +function Wait-ForPort { + param( + [int]$Port, + [int]$TimeoutSeconds + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + if (Test-PortOpen -Port $Port) { + return $true + } + Start-Sleep -Seconds 1 + } while ((Get-Date) -lt $deadline) + + return $false +} + +function ConvertTo-PowerShellLiteral { + param([string]$Value) + + return "'" + $Value.Replace("'", "''") + "'" +} + +function Start-BackgroundPowerShell { + param([string]$Command) + + $encodedCommand = [Convert]::ToBase64String( + [System.Text.Encoding]::Unicode.GetBytes($Command) + ) + return Start-Process ` + -FilePath "powershell.exe" ` + -WindowStyle Hidden ` + -ArgumentList @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-EncodedCommand", $encodedCommand + ) ` + -PassThru +} + $ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path $FrontDir = Join-Path $ProjectRoot "front" $LogDir = Join-Path $ProjectRoot "logs" @@ -64,24 +113,32 @@ Write-Host "项目目录:$ProjectRoot" Write-Section "1. 检查 Java" if (-not (Test-CommandExists "java")) { - Fail-WithHelp "没有找到 Java。" "请安装 Java 21,然后重新打开 PowerShell 或双击 start_windows.bat。验证命令:java -version" + Fail-WithHelp ` + "没有找到 Java。" ` + "请安装 Java 21,重新打开 PowerShell 后执行 java -version,确认显示 21 或更高版本。" } $javaMajor = Get-JavaMajorVersion if ($javaMajor -lt 21) { - Fail-WithHelp "当前 Java 版本低于 21。" "请安装 Java 21。安装成功后执行 java -version,应看到 21 或更高版本。" + Fail-WithHelp ` + "当前 Java 主版本为 $javaMajor,项目要求 Java 21 或更高版本。" ` + "请安装 Java 21,重新打开 PowerShell 后执行 java -version 进行确认。" } Write-Host "Java 检查通过:主版本 $javaMajor" Write-Section "2. 检查 Gradle Wrapper" $GradlewBat = Join-Path $ProjectRoot "gradlew.bat" if (-not (Test-Path -LiteralPath $GradlewBat)) { - Fail-WithHelp "没有找到 gradlew.bat。" "请确认你在完整项目目录中运行,本项目根目录应该包含 gradlew.bat。" + Fail-WithHelp ` + "没有找到 gradlew.bat。" ` + "请确认当前目录是完整的项目根目录。" } Push-Location $ProjectRoot try { & $GradlewBat --version *> $null if ($LASTEXITCODE -ne 0) { - Fail-WithHelp "gradlew.bat 无法正常执行。" "请确认 Java 21 已正确安装,并且项目目录没有被安全软件拦截。" + Fail-WithHelp ` + "gradlew.bat 无法正常执行。" ` + "请确认 Java 21 已正确安装,且项目目录没有被安全软件拦截。" } } finally { Pop-Location @@ -90,29 +147,45 @@ Write-Host "Gradle Wrapper 检查通过:$GradlewBat" Write-Section "3. 检查 Node.js 与 pnpm" if (-not (Test-CommandExists "node")) { - Fail-WithHelp "没有找到 Node.js。" "请安装 Node.js LTS。安装成功后执行 node -v,应看到版本号。" + Fail-WithHelp ` + "没有找到 Node.js。" ` + "请安装 Node.js LTS,重新打开 PowerShell 后执行 node -v 进行确认。" } Write-Host "Node.js 检查通过:$(& node -v)" if (-not (Test-CommandExists "pnpm")) { - Fail-WithHelp "没有找到 pnpm。" "请先安装 pnpm:在 PowerShell 中执行 corepack enable,然后执行 corepack prepare pnpm@10.20.0 --activate。验证命令:pnpm -v" + Fail-WithHelp ` + "没有找到 pnpm。" ` + "请执行 corepack enable,再执行 corepack prepare pnpm@10.20.0 --activate,最后用 pnpm -v 验证。" } Write-Host "pnpm 检查通过:$(& pnpm -v)" if (-not (Test-Path -LiteralPath $FrontDir)) { - Fail-WithHelp "没有找到 front 目录。" "请确认项目下载完整,项目根目录应包含 front 文件夹。" + Fail-WithHelp ` + "没有找到 front 目录。" ` + "请确认项目下载完整,项目根目录下应包含 front 文件夹。" } $FrontNodeModules = Join-Path $FrontDir "node_modules" if (-not (Test-Path -LiteralPath $FrontNodeModules)) { - Fail-WithHelp "front 目录还没有安装依赖。" "请在目录 $FrontDir 中执行 pnpm install。成功后应看到 node_modules 文件夹。" + Fail-WithHelp ` + "前端依赖尚未安装。" ` + "请在 $FrontDir 目录执行 pnpm install,成功后应看到 node_modules 文件夹。" } Write-Host "前端依赖检查通过:$FrontNodeModules" Write-Section "4. 准备运行目录" -foreach ($dir in @($LogDir, $TargetLogDir, $DbDir, $DataDir, $OutputDir, $CacheDir, $ChromeProfileDir)) { +foreach ($dir in @( + $LogDir, + $TargetLogDir, + $DbDir, + $DataDir, + $OutputDir, + $CacheDir, + $ChromeProfileDir +)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } -Write-Host "运行目录已准备:db、data、output、logs、target\logs、target\cache、chrome-profile" +Write-Host "运行目录已准备完成。" if (-not $env:SPRING_DATASOURCE_URL) { $env:SPRING_DATASOURCE_URL = "jdbc:sqlite:$(Join-Path $DbDir 'getjobs.db')" @@ -125,52 +198,66 @@ if (-not $env:APP_BROWSER_USER_DATA_DIR) { } $env:JAVA_TOOL_OPTIONS = "-Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8" -Write-Section "5. 启动后端与前端" $BackendLog = Join-Path $LogDir "windows-backend.log" $FrontendLog = Join-Path $LogDir "windows-frontend.log" -$BackendCommand = @" -Set-Location -LiteralPath '$ProjectRoot' -`$env:JAVA_TOOL_OPTIONS = '$($env:JAVA_TOOL_OPTIONS)' -`$env:SPRING_DATASOURCE_URL = '$($env:SPRING_DATASOURCE_URL)' -`$env:LOGGING_FILE_NAME = '$($env:LOGGING_FILE_NAME)' -`$env:APP_BROWSER_USER_DATA_DIR = '$($env:APP_BROWSER_USER_DATA_DIR)' -.\gradlew.bat bootRun *> '$BackendLog' -"@ +$projectRootLiteral = ConvertTo-PowerShellLiteral $ProjectRoot +$frontDirLiteral = ConvertTo-PowerShellLiteral $FrontDir +$backendLogLiteral = ConvertTo-PowerShellLiteral $BackendLog +$frontendLogLiteral = ConvertTo-PowerShellLiteral $FrontendLog +$javaToolOptionsLiteral = ConvertTo-PowerShellLiteral $env:JAVA_TOOL_OPTIONS +$datasourceUrlLiteral = ConvertTo-PowerShellLiteral $env:SPRING_DATASOURCE_URL +$loggingFileLiteral = ConvertTo-PowerShellLiteral $env:LOGGING_FILE_NAME +$chromeProfileLiteral = ConvertTo-PowerShellLiteral $env:APP_BROWSER_USER_DATA_DIR -$FrontendCommand = @" -Set-Location -LiteralPath '$FrontDir' -pnpm dev *> '$FrontendLog' +Write-Section "5. 启动前端" +if (Test-PortOpen -Port 6866) { + Write-Host "前端端口 6866 已在监听,跳过重复启动。" -ForegroundColor Green +} else { + $FrontendCommand = @" +Set-Location -LiteralPath $frontDirLiteral +& pnpm dev *>> $frontendLogLiteral "@ + $frontendProcess = Start-BackgroundPowerShell -Command $FrontendCommand + Write-Host "前端启动进程:$($frontendProcess.Id)" + Write-Host "前端日志:$FrontendLog" -Start-Process -FilePath "powershell.exe" -WindowStyle Hidden -ArgumentList @("-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", $BackendCommand) -Start-Sleep -Seconds 3 -Start-Process -FilePath "powershell.exe" -WindowStyle Hidden -ArgumentList @("-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", $FrontendCommand) - -Write-Host "后端启动日志:$BackendLog" -Write-Host "前端启动日志:$FrontendLog" -Write-Host "" -Write-Host "访问地址:" -Write-Host "前端:http://localhost:6866" -Write-Host "后端:http://localhost:8888" - -Write-Section "6. 简单连通性检查" -Start-Sleep -Seconds 8 -$backendReady = Test-PortOpen 8888 -$frontendReady = Test-PortOpen 6866 - -if ($backendReady) { - Write-Host "后端端口 8888 已监听。" -ForegroundColor Green -} else { - Write-Host "后端端口 8888 暂未监听,请稍等或查看:$BackendLog" -ForegroundColor Yellow + if (-not (Wait-ForPort -Port 6866 -TimeoutSeconds 60)) { + Fail-WithHelp ` + "前端在 60 秒内未能监听 6866 端口。" ` + "请查看日志:$FrontendLog" + } + Write-Host "前端端口 6866 已就绪。" -ForegroundColor Green } -if ($frontendReady) { - Write-Host "前端端口 6866 已监听。" -ForegroundColor Green +Write-Section "6. 启动后端" +if (Test-PortOpen -Port 8888) { + Write-Host "后端端口 8888 已在监听,跳过重复启动。" -ForegroundColor Green } else { - Write-Host "前端端口 6866 暂未监听,请稍等或查看:$FrontendLog" -ForegroundColor Yellow + $BackendCommand = @" +Set-Location -LiteralPath $projectRootLiteral +`$env:JAVA_TOOL_OPTIONS = $javaToolOptionsLiteral +`$env:SPRING_DATASOURCE_URL = $datasourceUrlLiteral +`$env:LOGGING_FILE_NAME = $loggingFileLiteral +`$env:APP_BROWSER_USER_DATA_DIR = $chromeProfileLiteral +& (Join-Path $projectRootLiteral 'gradlew.bat') bootRun *>> $backendLogLiteral +"@ + $backendProcess = Start-BackgroundPowerShell -Command $BackendCommand + Write-Host "后端启动进程:$($backendProcess.Id)" + Write-Host "后端日志:$BackendLog" + + if (-not (Wait-ForPort -Port 8888 -TimeoutSeconds 90)) { + Fail-WithHelp ` + "后端在 90 秒内未能监听 8888 端口。" ` + "请查看日志:$BackendLog" + } + Write-Host "后端端口 8888 已就绪。" -ForegroundColor Green } +Write-Section "7. 启动完成" +Write-Host "前端:http://localhost:6866" +Write-Host "环境配置:http://localhost:6866/env-config" +Write-Host "后端健康检查:http://localhost:8888/api/health" Write-Host "" -Write-Host "如需停止服务,可以运行:bin\kill-services.bat" +Write-Host "如果页面没有自动刷新,请在浏览器中按 Ctrl+R。" exit 0 From e42475b6f70f8fa45f5cdebc5ef9f05c14bafe11 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Sat, 22 Aug 2026 12:32:03 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A=20Alter=20=E6=9C=AC=E5=9C=B0=E5=90=AF=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + WINDOWS_SETUP.md | 43 +++++- front/start-dev.mjs | 7 +- scripts/run_backend.ps1 | 122 ++++++++++++++++++ scripts/run_frontend.ps1 | 63 +++++++++ .../application/config/StartupRunner.java | 36 ++++-- .../config/StaticServerConfiguration.java | 9 ++ .../controller/HealthController.java | 37 ++++-- src/main/resources/application.yaml | 5 +- .../config/StartupRunnerDegradedModeTest.java | 16 +++ .../controller/HealthControllerTest.java | 22 ++++ start_windows.ps1 | 120 +++++++++++++---- 12 files changed, 421 insertions(+), 61 deletions(-) create mode 100644 scripts/run_backend.ps1 create mode 100644 scripts/run_frontend.ps1 diff --git a/.env.example b/.env.example index 099819a..4cea23c 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,8 @@ API_PROXY_TARGET=http://backend:8888 # Spring Boot 运行配置 SERVER_PORT=8888 APP_AUTO_OPEN_BROWSER=false +APP_BROWSER_INITIALIZE_ON_STARTUP=false +APP_STATIC_SERVER_ENABLED=true TZ=Asia/Shanghai JAVA_OPTS=-Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 diff --git a/WINDOWS_SETUP.md b/WINDOWS_SETUP.md index 6cbac9a..172af6e 100644 --- a/WINDOWS_SETUP.md +++ b/WINDOWS_SETUP.md @@ -35,7 +35,7 @@ chrome-extension 你当前项目目录是: ```text -C:\Users\10578\Documents\New project 3\AI-JobPilot +C:\Users\10578\Documents\New project 3 ``` 后面的命令如果需要手动执行,都在这个目录或它的 `front` 子目录执行。 @@ -93,9 +93,9 @@ start_windows.bat 3. 检查 Node.js 和 pnpm。 4. 检查 `front\node_modules` 是否存在。 5. 准备 `db`、`data`、`output`、`logs`、`target\cache` 等目录。 -6. 启动后端。 -7. 启动前端。 -8. 简单检查 8888 和 6866 端口。 +6. 先启动前端,再启动后端,避免 6866 端口冲突。 +7. 分别检查前端页面和后端健康接口,而不只是检查端口是否被占用。 +8. 默认不自动打开管理页或招聘网站。 启动成功后打开: @@ -115,6 +115,37 @@ http://localhost:8888/api/health {"status":"UP","service":"GetJobs"} ``` +## 交给 Alter 启动 + +不要让 Alter 启动根目录的 `start_windows.bat`。该文件用于人工双击,会在后台拉起两个服务;Alter 应分别托管下面两个前台脚本。 + +### Frontend 组件 + +```text +脚本:C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe +参数:-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "C:\Users\10578\Documents\New project 3\scripts\run_frontend.ps1" +工作目录:C:\Users\10578\Documents\New project 3 +健康地址:http://127.0.0.1:6866 +``` + +### Backend 组件 + +```text +脚本:C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe +参数:-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "C:\Users\10578\Documents\New project 3\scripts\run_backend.ps1" +工作目录:C:\Users\10578\Documents\New project 3 +健康地址:http://127.0.0.1:8888/api/health +``` + +如果你只想手动运行脚本,也可以在 PowerShell 中分别执行: + +```powershell +.\scripts\run_frontend.ps1 +.\scripts\run_backend.ps1 +``` + +两个组件可以同时交给 Alter 启动。Backend 脚本会关闭后端对 6866 的额外占用,因此前后端不再抢端口。 + ## 第一次启动前安装前端依赖 如果脚本提示 `front 目录还没有安装依赖`,请在项目根目录执行: @@ -156,7 +187,7 @@ http://localhost:8888/api/health 另开一个 PowerShell 窗口,进入前端目录: ```powershell -cd C:\Users\10578\Documents\New project 3\AI-JobPilot\front +cd "C:\Users\10578\Documents\New project 3\front" pnpm dev ``` @@ -183,7 +214,7 @@ Boss 和智联建议使用 Chrome Bridge。 5. 选择: ```text -C:\Users\10578\Documents\New project 3\AI-JobPilot\chrome-extension +C:\Users\10578\Documents\New project 3\chrome-extension ``` 6. 回到 `http://localhost:6866` 并刷新页面。 diff --git a/front/start-dev.mjs b/front/start-dev.mjs index 14507a9..80213f7 100644 --- a/front/start-dev.mjs +++ b/front/start-dev.mjs @@ -19,10 +19,11 @@ const config = require(configPath); const port = config.port || 3000; const hostname = config.development?.hostname || 'localhost'; -// 启动Next.js开发服务器 -const nextDev = spawn('npx', ['next', 'dev', '-p', port.toString(), '-H', hostname], { +// 直接使用当前项目安装的 Next.js CLI,避免 npx/shell 产生脱离托管的中间进程。 +const nextCli = require.resolve('next/dist/bin/next'); +const nextDev = spawn(process.execPath, [nextCli, 'dev', '-p', port.toString(), '-H', hostname], { stdio: 'inherit', - shell: true, + shell: false, env: { ...process.env, API_BASE_URL: config.api?.baseUrl ?? '', diff --git a/scripts/run_backend.ps1 b/scripts/run_backend.ps1 new file mode 100644 index 0000000..597272e --- /dev/null +++ b/scripts/run_backend.ps1 @@ -0,0 +1,122 @@ +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +function Fail-WithHelp { + param( + [string]$Message, + [string]$Fix + ) + + Write-Host "错误:$Message" -ForegroundColor Red + Write-Host "解决办法:$Fix" -ForegroundColor Yellow + exit 1 +} + +function Get-PortOwnerDescription { + param([int]$Port) + + try { + $connection = Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction Stop | + Select-Object -First 1 + if ($null -eq $connection) { + return $null + } + + $process = Get-CimInstance Win32_Process -Filter "ProcessId=$($connection.OwningProcess)" -ErrorAction Stop + return "PID $($process.ProcessId),进程 $($process.Name),命令 $($process.CommandLine)" + } catch { + return $null + } +} + +function Get-JavaMajorVersion { + $versionText = (& cmd.exe /d /c "java -version 2>&1" | Out-String) + if ($versionText -match 'version "(\d+)') { + return [int]$Matches[1] + } + if ($versionText -match 'openjdk (\d+)') { + return [int]$Matches[1] + } + return 0 +} + +$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$GradlewBat = Join-Path $ProjectRoot "gradlew.bat" +$DbDir = Join-Path $ProjectRoot "db" +$DataDir = Join-Path $ProjectRoot "data" +$OutputDir = Join-Path $ProjectRoot "output" +$CacheDir = Join-Path $ProjectRoot "target\cache" +$LogDir = Join-Path $ProjectRoot "target\logs" +$ChromeProfileDir = Join-Path $ProjectRoot "chrome-profile" + +if ($null -eq (Get-Command "java" -ErrorAction SilentlyContinue)) { + Fail-WithHelp "没有找到 Java。" "请安装 Java 21,重新打开 Alter 后再启动 Backend。" +} +$javaMajor = Get-JavaMajorVersion +if ($javaMajor -lt 21) { + Fail-WithHelp "当前 Java 主版本为 $javaMajor,项目要求 Java 21 或更高版本。" "请安装 Java 21,重新打开 Alter 后再启动 Backend。" +} +if (-not (Test-Path -LiteralPath $GradlewBat)) { + Fail-WithHelp "没有找到 $GradlewBat。" "请确认 Alter 的工作目录指向项目根目录。" +} + +$portOwner = Get-PortOwnerDescription -Port 8888 +if ($portOwner) { + Fail-WithHelp "后端端口 8888 已被占用:$portOwner" "请先在 Alter 停止旧 Backend,确认端口释放后再启动。" +} + +foreach ($dir in @($DbDir, $DataDir, $OutputDir, $CacheDir, $LogDir, $ChromeProfileDir)) { + New-Item -ItemType Directory -Force -Path $dir | Out-Null +} + +if (-not $env:SPRING_DATASOURCE_URL) { + $env:SPRING_DATASOURCE_URL = "jdbc:sqlite:$(Join-Path $DbDir 'getjobs.db')" +} +if (-not $env:APP_DATA_DIR) { + $env:APP_DATA_DIR = $DataDir +} +if (-not $env:APP_OUTPUT_DIR) { + $env:APP_OUTPUT_DIR = $OutputDir +} +if (-not $env:APP_CACHE_DIR) { + $env:APP_CACHE_DIR = $CacheDir +} +if (-not $env:APP_LOG_DIR) { + $env:APP_LOG_DIR = $LogDir +} +if (-not $env:LOGGING_FILE_NAME) { + $env:LOGGING_FILE_NAME = Join-Path $LogDir "get-jobs.log" +} +if (-not $env:APP_BROWSER_USER_DATA_DIR) { + $env:APP_BROWSER_USER_DATA_DIR = $ChromeProfileDir +} +if (-not $env:APP_AUTO_OPEN_BROWSER) { + $env:APP_AUTO_OPEN_BROWSER = "false" +} +if (-not $env:APP_BROWSER_INITIALIZE_ON_STARTUP) { + $env:APP_BROWSER_INITIALIZE_ON_STARTUP = "false" +} +if (-not $env:APP_STATIC_SERVER_ENABLED) { + $env:APP_STATIC_SERVER_ENABLED = "false" +} + +$encodingOptions = "-Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8" +if ([string]::IsNullOrWhiteSpace($env:JAVA_TOOL_OPTIONS)) { + $env:JAVA_TOOL_OPTIONS = $encodingOptions +} elseif ($env:JAVA_TOOL_OPTIONS -notmatch "-Dfile\.encoding=") { + $env:JAVA_TOOL_OPTIONS = "$($env:JAVA_TOOL_OPTIONS) $encodingOptions" +} + +Write-Host "启动投递牛马后端:$ProjectRoot" +Write-Host "健康检查:http://127.0.0.1:8888/api/health" +Write-Host "启动阶段不会打开管理页或招聘网站;使用平台功能时浏览器会按需启动。" + +Push-Location $ProjectRoot +try { + & $GradlewBat --no-daemon bootRun + $exitCode = $LASTEXITCODE +} finally { + Pop-Location +} + +exit $exitCode diff --git a/scripts/run_frontend.ps1 b/scripts/run_frontend.ps1 new file mode 100644 index 0000000..31d8d06 --- /dev/null +++ b/scripts/run_frontend.ps1 @@ -0,0 +1,63 @@ +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +function Fail-WithHelp { + param( + [string]$Message, + [string]$Fix + ) + + Write-Host "错误:$Message" -ForegroundColor Red + Write-Host "解决办法:$Fix" -ForegroundColor Yellow + exit 1 +} + +function Get-PortOwnerDescription { + param([int]$Port) + + try { + $connection = Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction Stop | + Select-Object -First 1 + if ($null -eq $connection) { + return $null + } + + $process = Get-CimInstance Win32_Process -Filter "ProcessId=$($connection.OwningProcess)" -ErrorAction Stop + return "PID $($process.ProcessId),进程 $($process.Name),命令 $($process.CommandLine)" + } catch { + return $null + } +} + +$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$FrontDir = Join-Path $ProjectRoot "front" +$StartScript = Join-Path $FrontDir "start-dev.mjs" +$NodeModules = Join-Path $FrontDir "node_modules" + +if ($null -eq (Get-Command "node" -ErrorAction SilentlyContinue)) { + Fail-WithHelp "没有找到 Node.js。" "请安装 Node.js LTS,重新打开 Alter 后再启动 Frontend。" +} +if (-not (Test-Path -LiteralPath $StartScript)) { + Fail-WithHelp "没有找到 $StartScript。" "请确认 Alter 的工作目录指向项目根目录。" +} +if (-not (Test-Path -LiteralPath $NodeModules)) { + Fail-WithHelp "前端依赖尚未安装。" "请在 $FrontDir 执行 corepack pnpm install。" +} + +$portOwner = Get-PortOwnerDescription -Port 6866 +if ($portOwner) { + Fail-WithHelp "前端端口 6866 已被占用:$portOwner" "请先在 Alter 停止旧 Frontend,确认端口释放后再启动。" +} + +Write-Host "启动投递牛马前端:$FrontDir" +Write-Host "访问地址:http://127.0.0.1:6866" + +Push-Location $FrontDir +try { + & node $StartScript + $exitCode = $LASTEXITCODE +} finally { + Pop-Location +} + +exit $exitCode diff --git a/src/main/java/com/getjobs/application/config/StartupRunner.java b/src/main/java/com/getjobs/application/config/StartupRunner.java index 1a005f2..197f3a4 100644 --- a/src/main/java/com/getjobs/application/config/StartupRunner.java +++ b/src/main/java/com/getjobs/application/config/StartupRunner.java @@ -26,9 +26,12 @@ public class StartupRunner implements ApplicationRunner { @Value("${server.port:8888}") private int backendPort; - @Value("${app.auto-open-browser:true}") + @Value("${app.auto-open-browser:false}") private boolean autoOpenBrowser; + @Value("${app.browser.initialize-on-startup:false}") + private boolean initializeBrowserOnStartup; + private static final int FRONTEND_PORT = 6866; private static final String FRONTEND_URL = "http://localhost:" + FRONTEND_PORT; private static final String BACKEND_URL = "http://localhost:"; @@ -38,22 +41,27 @@ public class StartupRunner implements ApplicationRunner { @Override public void run(ApplicationArguments args) throws Exception { - String urlToOpen = determineUrlToOpen(); - if (urlToOpen != null && autoOpenBrowser) { - openBrowser(urlToOpen); - } else if (urlToOpen != null) { - log.info("已关闭自动打开浏览器,请手动访问管理页面: {}", urlToOpen); + if (autoOpenBrowser) { + String urlToOpen = determineUrlToOpen(); + if (urlToOpen != null) { + openBrowser(urlToOpen); + } else { + log.info("未找到可用的管理页面,跳过自动打开浏览器"); + } } else { - log.info("未找到可用的管理页面,跳过自动打开浏览器"); + log.info("已关闭自动打开管理页面,请手动访问 http://localhost:{}", FRONTEND_PORT); } - // 在尝试打开管理页面之后,初始化 Playwright(满足“先打开管理页,再实例化”) - try { - playwrightManager.init(); - } catch (Exception e) { - // 浏览器自动化属于可降级能力。初始化失败时保留配置、岗位数据和 AI 接口, - // 后续真正使用 Playwright 时仍可再次尝试初始化。 - log.error("Playwright 初始化失败,后台将以降级模式继续运行: {}", e.getMessage(), e); + if (initializeBrowserOnStartup) { + try { + playwrightManager.init(); + } catch (Exception e) { + // 浏览器自动化属于可降级能力。初始化失败时保留配置、岗位数据和 AI 接口, + // 后续真正使用 Playwright 时仍可再次尝试初始化。 + log.error("Playwright 初始化失败,后台将以降级模式继续运行: {}", e.getMessage(), e); + } + } else { + log.info("已关闭启动时浏览器自动化初始化;使用招聘平台功能时将按需启动浏览器"); } } diff --git a/src/main/java/com/getjobs/application/config/StaticServerConfiguration.java b/src/main/java/com/getjobs/application/config/StaticServerConfiguration.java index f515457..aa87f7f 100644 --- a/src/main/java/com/getjobs/application/config/StaticServerConfiguration.java +++ b/src/main/java/com/getjobs/application/config/StaticServerConfiguration.java @@ -2,6 +2,7 @@ import org.apache.catalina.connector.Connector; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; import org.springframework.boot.web.server.WebServerFactoryCustomizer; import org.springframework.context.annotation.Bean; @@ -26,9 +27,17 @@ public class StaticServerConfiguration { private static final String DIST_PATH = "src/main/resources/dist"; private static final String STATIC_PATH = "src/main/resources/static"; + @Value("${app.static-server.enabled:true}") + private boolean staticServerEnabled; + @Bean public WebServerFactoryCustomizer servletContainer() { return server -> { + if (!staticServerEnabled) { + log.info("已关闭后端静态资源附加端口,6866 端口将只由前端服务使用"); + return; + } + // 检查前端 dev 服务是否运行 boolean hasFrontendDev = detectFrontendDevServer(); diff --git a/src/main/java/com/getjobs/application/controller/HealthController.java b/src/main/java/com/getjobs/application/controller/HealthController.java index 60ef229..e714592 100644 --- a/src/main/java/com/getjobs/application/controller/HealthController.java +++ b/src/main/java/com/getjobs/application/controller/HealthController.java @@ -26,23 +26,40 @@ public class HealthController { @GetMapping("/health") public ResponseEntity> health() { Map response = new HashMap<>(); - boolean browserAvailable = playwrightManager.isInitialized(); + boolean browserInitialized = playwrightManager.isInitialized(); + boolean browserInitializing = playwrightManager.isInitializing(); + String initializationError = playwrightManager.getLastInitializationError(); + boolean browserFailed = !browserInitialized + && !browserInitializing + && initializationError != null + && !initializationError.isBlank(); Map browserAutomation = new HashMap<>(); - browserAutomation.put("available", browserAvailable); - browserAutomation.put("initialized", browserAvailable); - browserAutomation.put("initializing", playwrightManager.isInitializing()); - browserAutomation.put("message", browserAvailable - ? "浏览器自动化运行正常" - : firstNonBlank(playwrightManager.getLastInitializationError(), "浏览器自动化暂不可用")); + browserAutomation.put("available", !browserFailed); + browserAutomation.put("initialized", browserInitialized); + browserAutomation.put("initializing", browserInitializing); + browserAutomation.put("message", browserMessage( + browserInitialized, + browserInitializing, + initializationError + )); - response.put("status", browserAvailable ? "UP" : "DEGRADED"); + response.put("status", browserFailed ? "DEGRADED" : "UP"); response.put("timestamp", System.currentTimeMillis()); response.put("service", "GetJobs"); response.put("browserAutomation", browserAutomation); return ResponseEntity.ok(response); } - private String firstNonBlank(String value, String fallback) { - return value == null || value.isBlank() ? fallback : value; + private String browserMessage(boolean initialized, boolean initializing, String initializationError) { + if (initialized) { + return "浏览器自动化运行正常"; + } + if (initializing) { + return "浏览器自动化正在初始化"; + } + if (initializationError != null && !initializationError.isBlank()) { + return initializationError; + } + return "浏览器自动化尚未启动,将在使用招聘平台功能时按需初始化"; } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 5382a1f..3c2f3a9 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -40,13 +40,16 @@ server: port: ${SERVER_PORT:8888} # 后端 API 固定端口 app: - auto-open-browser: ${APP_AUTO_OPEN_BROWSER:true} + auto-open-browser: ${APP_AUTO_OPEN_BROWSER:false} + static-server: + enabled: ${APP_STATIC_SERVER_ENABLED:true} paths: data-dir: ${APP_DATA_DIR:./data} output-dir: ${APP_OUTPUT_DIR:./output} cache-dir: ${APP_CACHE_DIR:./target/cache} log-dir: ${APP_LOG_DIR:./target/logs} browser: + initialize-on-startup: ${APP_BROWSER_INITIALIZE_ON_STARTUP:false} # Windows 示例:C:\Users\YourName\AppData\Local\AI-JobPilot\chrome-profile # macOS 示例:/Users/YourName/Library/Application Support/AI-JobPilot/chrome-profile user-data-dir: ${APP_BROWSER_USER_DATA_DIR:} diff --git a/src/test/java/com/getjobs/application/config/StartupRunnerDegradedModeTest.java b/src/test/java/com/getjobs/application/config/StartupRunnerDegradedModeTest.java index 9c2187a..9641cb7 100644 --- a/src/test/java/com/getjobs/application/config/StartupRunnerDegradedModeTest.java +++ b/src/test/java/com/getjobs/application/config/StartupRunnerDegradedModeTest.java @@ -7,6 +7,7 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; class StartupRunnerDegradedModeTest { @@ -18,8 +19,23 @@ void keepsBackendRunningWhenPlaywrightInitializationFails() { StartupRunner runner = new StartupRunner(); ReflectionTestUtils.setField(runner, "playwrightManager", playwrightManager); ReflectionTestUtils.setField(runner, "autoOpenBrowser", false); + ReflectionTestUtils.setField(runner, "initializeBrowserOnStartup", true); ReflectionTestUtils.setField(runner, "backendPort", 8888); assertThatCode(() -> runner.run(null)).doesNotThrowAnyException(); } + + @Test + void skipsPlaywrightInitializationByDefault() { + PlaywrightManager playwrightManager = mock(PlaywrightManager.class); + + StartupRunner runner = new StartupRunner(); + ReflectionTestUtils.setField(runner, "playwrightManager", playwrightManager); + ReflectionTestUtils.setField(runner, "autoOpenBrowser", false); + ReflectionTestUtils.setField(runner, "initializeBrowserOnStartup", false); + ReflectionTestUtils.setField(runner, "backendPort", 8888); + + assertThatCode(() -> runner.run(null)).doesNotThrowAnyException(); + verifyNoInteractions(playwrightManager); + } } diff --git a/src/test/java/com/getjobs/application/controller/HealthControllerTest.java b/src/test/java/com/getjobs/application/controller/HealthControllerTest.java index 4ec2940..a006be7 100644 --- a/src/test/java/com/getjobs/application/controller/HealthControllerTest.java +++ b/src/test/java/com/getjobs/application/controller/HealthControllerTest.java @@ -43,4 +43,26 @@ void reportsUpWhenBrowserAutomationIsReady() { assertThat(controller.health().getBody()).containsEntry("status", "UP"); } + + @Test + void reportsUpBeforeLazyBrowserInitializationStarts() { + PlaywrightManager playwrightManager = mock(PlaywrightManager.class); + when(playwrightManager.isInitialized()).thenReturn(false); + when(playwrightManager.isInitializing()).thenReturn(false); + when(playwrightManager.getLastInitializationError()).thenReturn(""); + + HealthController controller = new HealthController(playwrightManager); + Map response = controller.health().getBody(); + + assertThat(response).containsEntry("status", "UP"); + + @SuppressWarnings("unchecked") + Map browserAutomation = + (Map) response.get("browserAutomation"); + assertThat(browserAutomation) + .containsEntry("available", true) + .containsEntry("initialized", false) + .containsEntry("initializing", false) + .containsEntry("message", "浏览器自动化尚未启动,将在使用招聘平台功能时按需初始化"); + } } diff --git a/start_windows.ps1 b/start_windows.ps1 index 116304e..ccc76ba 100644 --- a/start_windows.ps1 +++ b/start_windows.ps1 @@ -58,15 +58,26 @@ function Test-PortOpen { } } -function Wait-ForPort { +function Test-HttpEndpoint { + param([string]$Url) + + try { + $response = Invoke-WebRequest -UseBasicParsing -Uri $Url -TimeoutSec 3 + return $response.StatusCode -eq 200 + } catch { + return $false + } +} + +function Wait-ForHttpEndpoint { param( - [int]$Port, + [string]$Url, [int]$TimeoutSeconds ) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) do { - if (Test-PortOpen -Port $Port) { + if (Test-HttpEndpoint -Url $Url) { return $true } Start-Sleep -Seconds 1 @@ -75,6 +86,46 @@ function Wait-ForPort { return $false } +function Get-PortOwner { + param([int]$Port) + + try { + $connection = Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction Stop | + Select-Object -First 1 + if ($null -eq $connection) { + return $null + } + return Get-CimInstance Win32_Process -Filter "ProcessId=$($connection.OwningProcess)" -ErrorAction Stop + } catch { + return $null + } +} + +function Get-PortOwnerDescription { + param([int]$Port) + + $process = Get-PortOwner -Port $Port + if ($null -eq $process) { + return "无法读取占用进程信息" + } + return "PID $($process.ProcessId),进程 $($process.Name),命令 $($process.CommandLine)" +} + +function Test-PortOwnedByProject { + param( + [int]$Port, + [string]$ProjectRoot, + [string]$ExpectedProcessPattern + ) + + $process = Get-PortOwner -Port $Port + if ($null -eq $process) { + return $false + } + return $process.Name -match $ExpectedProcessPattern -and + $process.CommandLine -like "*$ProjectRoot*" +} + function ConvertTo-PowerShellLiteral { param([string]$Value) @@ -196,62 +247,77 @@ if (-not $env:LOGGING_FILE_NAME) { if (-not $env:APP_BROWSER_USER_DATA_DIR) { $env:APP_BROWSER_USER_DATA_DIR = $ChromeProfileDir } +if (-not $env:APP_AUTO_OPEN_BROWSER) { + $env:APP_AUTO_OPEN_BROWSER = "false" +} +if (-not $env:APP_BROWSER_INITIALIZE_ON_STARTUP) { + $env:APP_BROWSER_INITIALIZE_ON_STARTUP = "false" +} +if (-not $env:APP_STATIC_SERVER_ENABLED) { + $env:APP_STATIC_SERVER_ENABLED = "false" +} $env:JAVA_TOOL_OPTIONS = "-Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8" $BackendLog = Join-Path $LogDir "windows-backend.log" $FrontendLog = Join-Path $LogDir "windows-frontend.log" -$projectRootLiteral = ConvertTo-PowerShellLiteral $ProjectRoot -$frontDirLiteral = ConvertTo-PowerShellLiteral $FrontDir $backendLogLiteral = ConvertTo-PowerShellLiteral $BackendLog $frontendLogLiteral = ConvertTo-PowerShellLiteral $FrontendLog -$javaToolOptionsLiteral = ConvertTo-PowerShellLiteral $env:JAVA_TOOL_OPTIONS -$datasourceUrlLiteral = ConvertTo-PowerShellLiteral $env:SPRING_DATASOURCE_URL -$loggingFileLiteral = ConvertTo-PowerShellLiteral $env:LOGGING_FILE_NAME -$chromeProfileLiteral = ConvertTo-PowerShellLiteral $env:APP_BROWSER_USER_DATA_DIR +$backendScriptLiteral = ConvertTo-PowerShellLiteral (Join-Path $ProjectRoot "scripts\run_backend.ps1") +$frontendScriptLiteral = ConvertTo-PowerShellLiteral (Join-Path $ProjectRoot "scripts\run_frontend.ps1") +$FrontendUrl = "http://127.0.0.1:6866/" +$BackendHealthUrl = "http://127.0.0.1:8888/api/health" Write-Section "5. 启动前端" -if (Test-PortOpen -Port 6866) { - Write-Host "前端端口 6866 已在监听,跳过重复启动。" -ForegroundColor Green +if ((Test-HttpEndpoint -Url $FrontendUrl) -and + (Test-PortOwnedByProject -Port 6866 -ProjectRoot $ProjectRoot -ExpectedProcessPattern '^node(\.exe)?$')) { + Write-Host "当前项目的前端已经正常运行,跳过重复启动。" -ForegroundColor Green +} elseif (Test-PortOpen -Port 6866) { + $owner = Get-PortOwnerDescription -Port 6866 + Fail-WithHelp ` + "前端端口 6866 已被占用,但不是当前项目可复用的健康前端:$owner" ` + "请先停止占用 6866 的旧进程,再重新运行本启动器。" } else { $FrontendCommand = @" -Set-Location -LiteralPath $frontDirLiteral -& pnpm dev *>> $frontendLogLiteral +& $frontendScriptLiteral *>> $frontendLogLiteral +exit `$LASTEXITCODE "@ $frontendProcess = Start-BackgroundPowerShell -Command $FrontendCommand Write-Host "前端启动进程:$($frontendProcess.Id)" Write-Host "前端日志:$FrontendLog" - if (-not (Wait-ForPort -Port 6866 -TimeoutSeconds 60)) { + if (-not (Wait-ForHttpEndpoint -Url $FrontendUrl -TimeoutSeconds 60)) { Fail-WithHelp ` - "前端在 60 秒内未能监听 6866 端口。" ` + "前端在 60 秒内未能通过 HTTP 健康检查。" ` "请查看日志:$FrontendLog" } - Write-Host "前端端口 6866 已就绪。" -ForegroundColor Green + Write-Host "前端 HTTP 服务已就绪。" -ForegroundColor Green } Write-Section "6. 启动后端" -if (Test-PortOpen -Port 8888) { - Write-Host "后端端口 8888 已在监听,跳过重复启动。" -ForegroundColor Green +if ((Test-HttpEndpoint -Url $BackendHealthUrl) -and + (Test-PortOwnedByProject -Port 8888 -ProjectRoot $ProjectRoot -ExpectedProcessPattern '^java(\.exe)?$')) { + Write-Host "当前项目的后端已经正常运行,跳过重复启动。" -ForegroundColor Green +} elseif (Test-PortOpen -Port 8888) { + $owner = Get-PortOwnerDescription -Port 8888 + Fail-WithHelp ` + "后端端口 8888 已被占用,但不是当前项目可复用的健康后端:$owner" ` + "请先停止占用 8888 的旧进程,再重新运行本启动器。" } else { $BackendCommand = @" -Set-Location -LiteralPath $projectRootLiteral -`$env:JAVA_TOOL_OPTIONS = $javaToolOptionsLiteral -`$env:SPRING_DATASOURCE_URL = $datasourceUrlLiteral -`$env:LOGGING_FILE_NAME = $loggingFileLiteral -`$env:APP_BROWSER_USER_DATA_DIR = $chromeProfileLiteral -& (Join-Path $projectRootLiteral 'gradlew.bat') bootRun *>> $backendLogLiteral +& $backendScriptLiteral *>> $backendLogLiteral +exit `$LASTEXITCODE "@ $backendProcess = Start-BackgroundPowerShell -Command $BackendCommand Write-Host "后端启动进程:$($backendProcess.Id)" Write-Host "后端日志:$BackendLog" - if (-not (Wait-ForPort -Port 8888 -TimeoutSeconds 90)) { + if (-not (Wait-ForHttpEndpoint -Url $BackendHealthUrl -TimeoutSeconds 120)) { Fail-WithHelp ` - "后端在 90 秒内未能监听 8888 端口。" ` + "后端在 120 秒内未能通过健康检查。" ` "请查看日志:$BackendLog" } - Write-Host "后端端口 8888 已就绪。" -ForegroundColor Green + Write-Host "后端健康检查已通过。" -ForegroundColor Green } Write-Section "7. 启动完成" From 18a36b80a0b48b1a669354bd3d9bf383eb4ee037 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Sat, 22 Aug 2026 12:48:13 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=B6=88?= =?UTF-8?q?=E9=99=A4=E7=AE=80=E5=8E=86=E9=85=8D=E7=BD=AE=E9=A1=B5=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E9=94=99=E8=AF=AF=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- front/app/ai-config/page.tsx | 91 ++++++++++++++---------- front/app/components/ProfileSwitcher.tsx | 30 +++----- tasks/fix-ai-config-startup-errors.md | 54 ++++++++++++++ 3 files changed, 117 insertions(+), 58 deletions(-) create mode 100644 tasks/fix-ai-config-startup-errors.md diff --git a/front/app/ai-config/page.tsx b/front/app/ai-config/page.tsx index 38dc0c0..205b494 100644 --- a/front/app/ai-config/page.tsx +++ b/front/app/ai-config/page.tsx @@ -1,14 +1,14 @@ 'use client' import { useEffect, useState } from 'react' -import { BiSave, BiBrain, BiInfoCircle, BiUpload } from 'react-icons/bi' +import { BiSave, BiBrain, BiInfoCircle, BiRefresh, BiUpload } from 'react-icons/bi' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' import PageHeader from '@/app/components/PageHeader' import ProfileSwitcher, { type Profile } from '@/app/components/ProfileSwitcher' -import { API_BASE } from '@/lib/api' +import { API_BASE, type ApiEnvelope, friendlyApiError, readApiResponse } from '@/lib/api' const MAX_RESUME_FILE_SIZE = 30 * 1024 * 1024 @@ -40,6 +40,24 @@ type SavedResume = { parseMessage?: string } +type ProfileAwareResponse = ApiEnvelope & { + currentProfile?: Profile | null + hasProfile?: boolean +} + +type BossConfigResponse = ProfileAwareResponse & { + config?: { + enableAi?: unknown + sayHi?: string + } +} + +type GeneratedAiConfig = { + introduce?: string + prompt?: string + sayHi?: string +} + type SaveOptions = { nextAiConfig?: AiConfig nextResumeText?: string @@ -65,6 +83,7 @@ export default function AiConfigPage() { const [generating, setGenerating] = useState(false) const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) const [statusMessage, setStatusMessage] = useState('') + const [loadError, setLoadError] = useState('') const [enableAi, setEnableAi] = useState(0) const [currentProfile, setCurrentProfile] = useState(null) const [hasProfile, setHasProfile] = useState(false) @@ -90,6 +109,7 @@ export default function AiConfigPage() { } const reloadCurrentData = async () => { + setLoadError('') await Promise.all([fetchAiConfig(), fetchBossConfig(), fetchResume(), fetchPriorityCompanies()]) setHasUnsavedChanges(false) setResumeDirty(false) @@ -102,8 +122,7 @@ export default function AiConfigPage() { method: 'GET', headers: { 'Content-Type': 'application/json' }, }) - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) - const result = await response.json() + const result = await readApiResponse(response, 'AI配置加载失败') as ProfileAwareResponse if (result.success && result.data) { setAiConfig({ introduce: result.data.introduce || '', @@ -115,7 +134,7 @@ export default function AiConfigPage() { setCurrentProfile(result.currentProfile || null) setHasProfile(Boolean(result.hasProfile || result.currentProfile)) } catch (error) { - console.error('加载AI配置失败:', error) + setLoadError((current) => current || friendlyApiError(error, 'AI配置加载失败')) setAiConfig({ introduce: '', prompt: '' }) } } @@ -123,7 +142,7 @@ export default function AiConfigPage() { const fetchResume = async () => { try { const response = await fetch(`${API_BASE}/api/ai/resume`) - const result = await response.json() + const result = await readApiResponse(response, '简历加载失败') as ProfileAwareResponse if (result.success && result.data) { setResumeText(result.data.resumeText || '') setResumeMeta({ @@ -138,7 +157,7 @@ export default function AiConfigPage() { setCurrentProfile(result.currentProfile || null) setHasProfile(Boolean(result.hasProfile || result.currentProfile)) } catch (error) { - console.error('加载简历失败:', error) + setLoadError((current) => current || friendlyApiError(error, '简历加载失败')) setResumeText('') setResumeMeta(null) } @@ -147,7 +166,7 @@ export default function AiConfigPage() { const fetchPriorityCompanies = async () => { try { const response = await fetch(`${API_BASE}/api/ai/companies/priority`) - const result = await response.json() + const result = await readApiResponse(response, '优先公司加载失败') as ProfileAwareResponse if (result.success && Array.isArray(result.data)) { setPriorityCompanies(result.data.map((it: PriorityCompany) => it.companyName).filter(Boolean).join('\n')) } else { @@ -156,7 +175,7 @@ export default function AiConfigPage() { setCurrentProfile(result.currentProfile || null) setHasProfile(Boolean(result.hasProfile || result.currentProfile)) } catch (error) { - console.error('加载优先公司失败:', error) + setLoadError((current) => current || friendlyApiError(error, '优先公司加载失败')) setPriorityCompanies('') } } @@ -167,30 +186,19 @@ export default function AiConfigPage() { method: 'GET', headers: { 'Content-Type': 'application/json' }, }) - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) - const result = await response.json() + const result = await readApiResponse(response, 'Boss配置加载失败') as BossConfigResponse setEnableAi(parseEnableAi(result?.config?.enableAi)) setSayHi(result?.config?.sayHi || '') setCurrentProfile(result?.currentProfile || null) setHasProfile(Boolean(result?.hasProfile || result?.currentProfile)) - } catch (e) { - console.error('加载Boss AI配置失败:', e) - } - } - - const parseJsonResponse = async (response: Response, fallback: string) => { - try { - const result = await response.json() - if (!response.ok || result?.success === false) { - throw new Error(result?.message || fallback) - } - return result } catch (error) { - if (error instanceof Error) throw error - throw new Error(fallback) + setLoadError((current) => current || friendlyApiError(error, 'Boss配置加载失败')) } } + const parseJsonResponse = async (response: Response, fallback: string) => + readApiResponse(response, fallback) + const toggleEnableAi = async () => { if (!hasProfile) { alert('请先新建档案') @@ -204,12 +212,11 @@ export default function AiConfigPage() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enableAi: next }), }) - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) + await readApiResponse(response, 'AI开关保存失败') setStatusMessage('AI开关已保存') - } catch (e) { - console.error('更新enable_ai失败:', e) + } catch (error) { setEnableAi((prev) => (prev ? 0 : 1)) - alert('切换失败,请检查后端服务连接') + alert(friendlyApiError(error, '切换失败,请检查后端服务连接')) } } @@ -219,7 +226,7 @@ export default function AiConfigPage() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(configToSave), }) - const result = await parseJsonResponse(response, 'AI配置保存失败') + const result = await parseJsonResponse(response, 'AI配置保存失败') return result.data } @@ -239,7 +246,7 @@ export default function AiConfigPage() { method: 'POST', body: resumeForm, }) - const result = await parseJsonResponse(response, '简历保存失败') + const result = await parseJsonResponse(response, '简历保存失败') if (result.data) { setResumeText(result.data.resumeText || textToSave) setResumeMeta({ @@ -265,7 +272,7 @@ export default function AiConfigPage() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(companies), }) - const result = await parseJsonResponse(response, '优先公司保存失败') + const result = await parseJsonResponse(response, '优先公司保存失败') return result.data } @@ -275,8 +282,7 @@ export default function AiConfigPage() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sayHi: nextSayHi, enableAi }), }) - if (!response.ok) throw new Error('Boss默认打招呼语保存失败') - return response.json() + await readApiResponse(response, 'Boss默认打招呼语保存失败') } const saveEverything = async ({ @@ -309,8 +315,7 @@ export default function AiConfigPage() { try { await saveEverything() } catch (error) { - console.error('保存AI配置失败:', error) - alert(error instanceof Error ? error.message : '保存失败,请检查服务器连接!') + alert(friendlyApiError(error, '保存失败,请检查服务器连接!')) } finally { setLoading(false) } @@ -336,7 +341,7 @@ export default function AiConfigPage() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ resumeText: latestResumeText }), }) - const result = await parseJsonResponse(response, 'AI配置生成失败') + const result = await parseJsonResponse(response, 'AI配置生成失败') const nextSayHi = result.data?.sayHi || '' const nextAiConfig = { @@ -358,8 +363,7 @@ export default function AiConfigPage() { setStatusMessage('已提交简历并生成AI配置') alert('已提交简历,并生成打招呼话术和AI配置!') } catch (error) { - console.error('提交简历并生成AI配置失败:', error) - alert(error instanceof Error ? error.message : '提交简历并生成AI配置失败') + alert(friendlyApiError(error, '提交简历并生成AI配置失败')) } finally { setGenerating(false) } @@ -423,6 +427,15 @@ export default function AiConfigPage() { }} /> + {loadError ? ( +
+ {loadError} + +
+ ) : null} + {!hasProfile ? (
请先在上方新建档案;没有档案时,下方简历、AI配置、平台参数和投递分析都不会写入。 diff --git a/front/app/components/ProfileSwitcher.tsx b/front/app/components/ProfileSwitcher.tsx index aae0c53..b6c7322 100644 --- a/front/app/components/ProfileSwitcher.tsx +++ b/front/app/components/ProfileSwitcher.tsx @@ -5,32 +5,17 @@ import { BiPlus, BiRefresh, BiTrash, BiUserCircle } from 'react-icons/bi' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Select } from '@/components/ui/select' -import { API_BASE } from '@/lib/api' +import { API_BASE, type ApiEnvelope, friendlyApiError, readApiResponse } from '@/lib/api' -type ApiResponse = { - success?: boolean - data?: unknown +type ApiResponse = ApiEnvelope & { current?: unknown - message?: string impactCounts?: Record totalRelatedCount?: number forceRequired?: boolean } const parseApiResponse = async (response: Response, fallback: string): Promise => { - let result: ApiResponse | null = null - try { - result = await response.json() - } catch { - result = null - } - if (response.status === 404) { - throw new Error('档案接口暂不可用,请重启后端服务后再试') - } - if (!response.ok || result?.success === false) { - throw new Error(result?.message || fallback) - } - return result || {} + return await readApiResponse(response, fallback) as ApiResponse } const parseDeleteResponse = async (response: Response, fallback: string): Promise => { @@ -77,9 +62,11 @@ export default function ProfileSwitcher({ onProfileChange, beforeSwitch, compact const [currentId, setCurrentId] = useState('') const [newName, setNewName] = useState('') const [loading, setLoading] = useState(false) + const [loadError, setLoadError] = useState('') const loadProfiles = async () => { setLoading(true) + setLoadError('') try { const response = await fetch(`${API_BASE}/api/profiles`) const result = await parseApiResponse(response, '档案加载失败') @@ -93,7 +80,7 @@ export default function ProfileSwitcher({ onProfileChange, beforeSwitch, compact setCurrentId('') } } catch (error) { - console.error('加载档案失败:', error) + setLoadError(friendlyApiError(error, '档案加载失败')) } finally { setLoading(false) } @@ -228,6 +215,11 @@ export default function ProfileSwitcher({ onProfileChange, beforeSwitch, compact + {loadError ? ( + + {loadError} + + ) : null}
) } diff --git a/tasks/fix-ai-config-startup-errors.md b/tasks/fix-ai-config-startup-errors.md new file mode 100644 index 0000000..a591a9e --- /dev/null +++ b/tasks/fix-ai-config-startup-errors.md @@ -0,0 +1,54 @@ +# 修复简历配置页启动阶段错误层 + +## 背景 + +Windows 本地开发时,Next.js 前端可能先于 Spring Boot 后端就绪。此时 `/api/profiles`、`/api/ai/config`、`/api/ai/resume`、`/api/ai/companies/priority` 和 `/api/boss/config` 会由开发代理返回 HTTP 500,响应体可能是纯文本 `Internal Server Error`。简历配置页当前直接调用 `response.json()`,并在已捕获的加载异常中调用 `console.error`,会触发 Next.js 开发错误层。 + +## 目标 + +- 后端短暂未就绪时,不再出现截图中的 HTTP 500、档案加载失败和 JSON SyntaxError 开发错误层。 +- 页面使用统一的安全响应解析,向用户显示可重试的友好提示。 +- 后端恢复后可通过页面刷新按钮重新加载,不影响正常配置读写。 + +## 允许修改范围 + +- `front/app/ai-config/page.tsx` +- `front/app/components/ProfileSwitcher.tsx` +- 本任务说明文件 + +## 禁止修改范围 + +- 数据库文件、数据库结构和用户档案数据 +- Spring Boot 控制器、服务和启动端口 +- API Key、Token、Cookie、`.env` 内容 +- 与简历配置页无关的页面或业务逻辑 + +## 已确定实现要求 + +- 复用 `front/lib/api.ts` 中的 `readApiResponse` 和 `friendlyApiError`。 +- 所有简历配置页初始化请求先按文本读取,再尝试 JSON 解析,避免纯文本 500 触发 SyntaxError。 +- 已处理的加载失败不得再使用会触发 Next.js 错误层的 `console.error`。 +- ProfileSwitcher 和简历配置页都保留用户可见的错误提示与重试入口。 +- 不吞掉保存操作的失败;保存失败仍应通过现有弹窗反馈。 + +## 验收标准 + +- 后端正常时,档案、AI 配置、简历、优先公司和 Boss 配置均能正常加载。 +- 后端返回纯文本 500 时,不出现未处理的 JSON SyntaxError。 +- 初始化失败时,不出现由 `console.error` 触发的 Next.js 开发错误层。 +- 页面展示友好错误消息,并可在后端恢复后手动重试。 +- 前端 lint 和生产构建通过,页面实测无控制台错误。 + +## 测试命令 + +- `pnpm lint` +- `pnpm build` +- 请求 `http://127.0.0.1:6866/api/profiles`、`/api/ai/config`、`/api/boss/config` 检查代理响应 +- 浏览器打开 `http://127.0.0.1:6866/ai-config`,检查页面与控制台 + +## 返回格式 + +- 修改文件清单 +- lint、构建、接口和页面验证结果 +- Git 分支、commit 名称、commit ID、push 分支和 PR 链接 +- 回滚命令