فهرست منبع

fix:更新README.md数据初始化内容

yangxiaolong 1 ماه پیش
والد
کامیت
24fbb75d8f
9فایلهای تغییر یافته به همراه740 افزوده شده و 41 حذف شده
  1. 25 17
      README.md
  2. 2 6
      backend/README.md
  3. 166 0
      database/mysql.sql
  4. BIN
      database/项目启动文档与数据准备.zip
  5. 241 12
      scripts/dev.ps1
  6. 181 0
      scripts/dev.sh
  7. 62 6
      scripts/init-databases.ps1
  8. 57 0
      scripts/init-databases.sh
  9. 6 0
      scripts/seed-knowledge.sh

+ 25 - 17
README.md

@@ -32,30 +32,36 @@
 
 ## 首次初始化
 
-第二阶段的数据库快照、Milvus 初始化入口和完整启动说明统一保存在:
+先准备 Docker Desktop、Python 3.12、uv、Node.js 22、pnpm 和本地 Milvus。项目只读取 `backend/.env`。
 
-[database/项目启动文档与数据准备.zip](database/项目启动文档与数据准备.zip)
+数据库完整快照位于 `database/mysql.sql`。数据库初始化脚本会把该文件复制进 Docker 的 MySQL 容器并自动导入,不需要本机安装 MySQL Client。
 
-首次运行时,将该压缩包解压到当前仓库根目录。仓库名称可以是 `harness_zbt` 或其他名称,目录结构统一使用相对路径
+先安装前后端依赖
 
-```text
-./
-├── backend/
-├── database/
-├── frontend/
-├── scripts/
-└── 项目启动文档与数据准备/
+```bash
+uv sync --project backend --locked
+pnpm --dir frontend install --frozen-lockfile
 ```
 
-随后打开相对路径 `项目启动文档与数据准备/智保通第一、二、三阶段启动指南.md`,按照“第二阶段:知识服务与运营分析”一节完成:
+初始化第二阶段 MySQL 数据,然后在本地 Milvus 启动后初始化知识库。
 
-1. 公共运行环境准备。
-2. 第二阶段 MySQL 快照导入。
-3. 后端与前端依赖安装。
-4. 第二阶段 Milvus 知识库初始化。
-5. 第二阶段统一启动。
+Windows PowerShell:
 
-压缩包中的第二阶段 `mysql.sql` 只重建 `insurance_s2_core`、`insurance_s2_agent` 和 `insurance_s2_analytics`,并已包含从第一阶段继承后的业务数据。
+```powershell
+.\scripts\init-databases.ps1
+powershell -ExecutionPolicy Bypass -File .\scripts\seed-knowledge.ps1
+```
+
+macOS / Linux:
+
+```bash
+./scripts/init-databases.sh
+./scripts/seed-knowledge.sh
+```
+
+默认 MySQL 容器名为 `zhibaotong-mysql`。如果实际容器名不同,Windows 使用 `.\scripts\init-databases.ps1 -ContainerName 容器名`,macOS/Linux 使用 `./scripts/init-databases.sh 容器名`。
+
+`database/mysql.sql` 只重建 `insurance_s2_core`、`insurance_s2_agent` 和 `insurance_s2_analytics`,并已包含从第一阶段继承后的业务数据,不再执行迁移、种子或阶段继承脚本。
 
 
 ## 启动
@@ -74,6 +80,8 @@ macOS / Linux:
 ./scripts/dev.sh
 ```
 
+启动脚本会检查端口、启动前后端、等待三个服务全部就绪,并在退出时统一清理子进程。
+
 - H5:http://127.0.0.1:5173
 - 管理后台:http://127.0.0.1:5174
 - API 文档:http://127.0.0.1:8000/docs

+ 2 - 6
backend/README.md

@@ -126,13 +126,9 @@ open -e .env
 
 ## 5. 首次初始化
 
-第二阶段首次运行所需的数据库快照、Milvus 初始化脚本和启动说明统一保存在:
+第二阶段完整数据库快照位于 `../database/mysql.sql`。请回到仓库根目录按照 `README.md` 执行 `scripts/init-databases.ps1` 或 `scripts/init-databases.sh`,脚本会将快照导入 Docker 中的 MySQL。
 
-[../database/项目启动文档与数据准备.zip](../database/项目启动文档与数据准备.zip)
-
-请将压缩包解压到当前仓库根目录,然后打开相对路径 `../项目启动文档与数据准备/智保通第一、二、三阶段启动指南.md`,按照“第二阶段:知识服务与运营分析”一节执行。该指南及配套脚本会自动定位仓库根目录,不依赖 `harness_zbt` 或其他固定目录名。
-
-压缩包中的第二阶段 `mysql.sql` 只初始化 `insurance_s2_core`、`insurance_s2_agent` 和 `insurance_s2_analytics`,并已包含第一阶段继承数据。不要再使用项目内旧 SQL,也不要额外执行 Alembic、`seed`、`inherit_stage_one` 或 `build_analytics`。
+`database/mysql.sql` 只初始化 `insurance_s2_core`、`insurance_s2_agent` 和 `insurance_s2_analytics`,并已包含第一阶段继承数据。不要额外执行 Alembic、`seed`、`inherit_stage_one` 或 `build_analytics`。
 
 ## 6. 启动项目
 

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 166 - 0
database/mysql.sql


BIN
database/项目启动文档与数据准备.zip


+ 241 - 12
scripts/dev.ps1

@@ -1,21 +1,250 @@
-$ErrorActionPreference = "Stop"
+[CmdletBinding()]
+param(
+    # 仅用于自动化验证;正常启动时保持 0,由 Ctrl+C 结束。
+    [ValidateRange(0, 3600)]
+    [int]$RunForSeconds = 0
+)
+
+$ErrorActionPreference = "Stop"
+
 $ProjectRoot = Split-Path -Parent $PSScriptRoot
 $FrontendPath = Join-Path $ProjectRoot "frontend"
-$FrontendJob = Start-Job -ScriptBlock {
-    param($WorkingDirectory)
-    Set-Location $WorkingDirectory
-    pnpm dev
-} -ArgumentList $FrontendPath
+$BackendPath = Join-Path $ProjectRoot "backend"
+$ManagedProcessIds = [System.Collections.Generic.HashSet[int]]::new()
+$StartedProcesses = @()
+
+function Get-DevPortListeners {
+    param([int]$Port)
+
+    return @(
+        Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
+    )
+}
+
+function Test-DevPortListening {
+    param([int]$Port)
+
+    $Endpoints = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().GetActiveTcpListeners()
+    return $null -ne ($Endpoints | Where-Object { $_.Port -eq $Port } | Select-Object -First 1)
+}
+
+function Assert-DevPortAvailable {
+    param(
+        [int]$Port,
+        [string]$ApplicationName
+    )
+
+    if (-not (Test-DevPortListening -Port $Port)) {
+        return
+    }
+
+    # 只有端口确实被占用时才调用较慢的 Windows 连接查询,以便展示 PID。
+    $Listeners = Get-DevPortListeners -Port $Port
+
+    $Owners = foreach ($Listener in $Listeners) {
+        $Process = Get-CimInstance Win32_Process `
+            -Filter "ProcessId=$($Listener.OwningProcess)" `
+            -ErrorAction SilentlyContinue
+        if ($null -ne $Process) {
+            "PID $($Process.ProcessId):$($Process.CommandLine)"
+        }
+        else {
+            "PID $($Listener.OwningProcess)"
+        }
+    }
+    throw "$ApplicationName 端口 $Port 已被占用。请先结束以下进程后重试:`n$($Owners -join "`n")"
+}
+
+function Update-ManagedProcessTree {
+    # Uvicorn reload、pnpm 和 Vite 都会派生子进程。持续记录完整进程树,
+    # 即使某个中间父进程先退出,最终清理时仍能找到它已经创建的子进程。
+    # Win32_Process 单次查询在部分 Windows 机器上约需半秒,逐个父 PID
+    # 查询会明显拖慢启动。因此每轮只取一次快照,在内存中展开进程树。
+    $AllProcesses = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue)
+    $AddedProcess = $true
+    while ($AddedProcess) {
+        $AddedProcess = $false
+        foreach ($Child in $AllProcesses) {
+            if (-not $ManagedProcessIds.Contains([int]$Child.ParentProcessId)) {
+                continue
+            }
+
+            $ChildId = [int]$Child.ProcessId
+            if ($ManagedProcessIds.Add($ChildId)) {
+                $AddedProcess = $true
+            }
+        }
+    }
+}
+
+function Test-ProcessRunning {
+    param([System.Diagnostics.Process]$Process)
+
+    try {
+        $Process.Refresh()
+        return -not $Process.HasExited
+    }
+    catch {
+        return $false
+    }
+}
+
+function Test-HttpEndpoint {
+    param([string]$Uri)
+
+    try {
+        $Response = Invoke-WebRequest `
+            -Uri $Uri `
+            -UseBasicParsing `
+            -TimeoutSec 2 `
+            -ErrorAction Stop
+        return $Response.StatusCode -ge 200 -and $Response.StatusCode -lt 500
+    }
+    catch {
+        return $false
+    }
+}
+
+function Wait-ForDevelopmentServices {
+    param(
+        [System.Diagnostics.Process]$FrontendProcess,
+        [System.Diagnostics.Process]$BackendProcess,
+        [int]$TimeoutSeconds = 45
+    )
+
+    $Deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+    while ((Get-Date) -lt $Deadline) {
+        Update-ManagedProcessTree
+
+        if (-not (Test-ProcessRunning -Process $FrontendProcess)) {
+            throw "前端开发服务器启动失败,请检查上方 pnpm 输出。"
+        }
+        if (-not (Test-ProcessRunning -Process $BackendProcess)) {
+            throw "后端开发服务器启动失败,请检查上方 Uvicorn 输出。"
+        }
+
+        $H5Ready = Test-HttpEndpoint -Uri "http://127.0.0.1:5173/"
+        $AdminReady = Test-HttpEndpoint -Uri "http://127.0.0.1:5174/"
+        $BackendReady = Test-HttpEndpoint -Uri "http://127.0.0.1:8000/docs"
+        if ($H5Ready -and $AdminReady -and $BackendReady) {
+            return
+        }
+
+        Start-Sleep -Milliseconds 300
+    }
+
+    throw "开发服务在 $TimeoutSeconds 秒内未全部就绪,请检查上方启动日志。"
+}
+
+function Stop-ManagedProcesses {
+    Write-Host "`n正在停止智保通开发服务..." -ForegroundColor Yellow
+
+    # 多刷新几次,覆盖清理开始前恰好生成的 reload/Vite 子进程。
+    1..3 | ForEach-Object {
+        Update-ManagedProcessTree
+        Start-Sleep -Milliseconds 100
+    }
+
+    $Processes = @(
+        Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
+            Where-Object {
+                $_.ProcessId -ne $PID -and
+                $ManagedProcessIds.Contains([int]$_.ProcessId)
+            }
+    )
+
+    # 子进程通常晚于父进程创建;倒序停止可避免父进程退出后留下孤儿。
+    $Processes |
+        Sort-Object CreationDate -Descending |
+        ForEach-Object {
+            Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
+        }
+
+    # 确认本次脚本启动的三个监听端口已经释放。
+    $Deadline = (Get-Date).AddSeconds(5)
+    while ((Get-Date) -lt $Deadline) {
+        $AnyPortListening = $false
+        foreach ($Port in 5173, 5174, 8000) {
+            if (Test-DevPortListening -Port $Port) {
+                $AnyPortListening = $true
+                break
+            }
+        }
+        if (-not $AnyPortListening) {
+            break
+        }
+        Start-Sleep -Milliseconds 200
+    }
+
+    Write-Host "智保通开发服务已停止。" -ForegroundColor Green
+}
+
+foreach ($Service in @(
+    @{ Port = 5173; Name = "H5" },
+    @{ Port = 5174; Name = "管理后台" },
+    @{ Port = 8000; Name = "后端 API" }
+)) {
+    Assert-DevPortAvailable -Port $Service.Port -ApplicationName $Service.Name
+}
+
+$PnpmPath = (Get-Command pnpm.cmd -ErrorAction Stop).Source
+$UvPath = (Get-Command uv.exe -ErrorAction Stop).Source
 
-Push-Location (Join-Path $ProjectRoot "backend")
 try {
+    Write-Host "正在启动 H5、管理后台和后端 API..." -ForegroundColor Cyan
+
+    $FrontendProcess = Start-Process `
+        -FilePath $PnpmPath `
+        -ArgumentList "dev" `
+        -WorkingDirectory $FrontendPath `
+        -NoNewWindow `
+        -PassThru
+    $StartedProcesses += $FrontendProcess
+    [void]$ManagedProcessIds.Add($FrontendProcess.Id)
+
+    $BackendProcess = Start-Process `
+        -FilePath $UvPath `
+        -ArgumentList @(
+            "run", "uvicorn", "zbt.main:app",
+            "--host", "127.0.0.1",
+            "--port", "8000",
+            "--reload"
+        ) `
+        -WorkingDirectory $BackendPath `
+        -NoNewWindow `
+        -PassThru
+    $StartedProcesses += $BackendProcess
+    [void]$ManagedProcessIds.Add($BackendProcess.Id)
+
+    Wait-ForDevelopmentServices `
+        -FrontendProcess $FrontendProcess `
+        -BackendProcess $BackendProcess
+
+    Write-Host ""
+    Write-Host "智保通开发服务已全部启动:" -ForegroundColor Green
     Write-Host "H5:http://127.0.0.1:5173"
     Write-Host "管理后台:http://127.0.0.1:5174"
-    Write-Host "后端API:http://127.0.0.1:8000/docs"
-    uv run uvicorn zbt.main:app --host 127.0.0.1 --port 8000 --reload
+    Write-Host "后端 API:http://127.0.0.1:8000/docs"
+    Write-Host "按 Ctrl+C 可一次性停止全部服务。" -ForegroundColor DarkGray
+
+    $ReadyAt = Get-Date
+    while ($true) {
+        Update-ManagedProcessTree
+
+        foreach ($StartedProcess in $StartedProcesses) {
+            if (-not (Test-ProcessRunning -Process $StartedProcess)) {
+                throw "开发服务进程 PID $($StartedProcess.Id) 意外退出,请检查上方日志。"
+            }
+        }
+
+        if ($RunForSeconds -gt 0 -and
+            ((Get-Date) - $ReadyAt).TotalSeconds -ge $RunForSeconds) {
+            break
+        }
+
+        Start-Sleep -Milliseconds 500
+    }
 }
 finally {
-    Pop-Location
-    Stop-Job -Job $FrontendJob -ErrorAction SilentlyContinue
-    Remove-Job -Job $FrontendJob -Force -ErrorAction SilentlyContinue
+    Stop-ManagedProcesses
 }

+ 181 - 0
scripts/dev.sh

@@ -0,0 +1,181 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+frontend_pid=""
+backend_pid=""
+cleanup_done=0
+run_for_seconds="${ZBT_DEV_RUN_SECONDS:-0}"
+
+die() {
+    echo "错误:$*" >&2
+    exit 1
+}
+
+require_command() {
+    local command_name="$1"
+    command -v "$command_name" >/dev/null 2>&1 || \
+        die "未找到 $command_name,请先安装并确保它位于 PATH 中。"
+}
+
+assert_port_available() {
+    local port="$1"
+    local application_name="$2"
+    local listeners
+
+    listeners="$(lsof -nP -iTCP:"$port" -sTCP:LISTEN 2>/dev/null || true)"
+    if [[ -n "$listeners" ]]; then
+        echo "$listeners" >&2
+        die "$application_name 端口 $port 已被占用,请先结束上方进程后重试。"
+    fi
+}
+
+process_group_is_alive() {
+    local group_id="$1"
+    [[ -n "$group_id" ]] && kill -0 -- "-$group_id" 2>/dev/null
+}
+
+stop_process_group() {
+    local group_id="$1"
+    if process_group_is_alive "$group_id"; then
+        kill -TERM -- "-$group_id" 2>/dev/null || true
+    fi
+}
+
+cleanup() {
+    local exit_status=$?
+    local deadline
+
+    if (( cleanup_done )); then
+        return "$exit_status"
+    fi
+    cleanup_done=1
+    trap - INT TERM HUP
+
+    if [[ -n "$frontend_pid" || -n "$backend_pid" ]]; then
+        echo
+        echo "正在停止智保通开发服务..."
+    fi
+
+    stop_process_group "$frontend_pid"
+    stop_process_group "$backend_pid"
+
+    deadline=$((SECONDS + 5))
+    while (( SECONDS < deadline )); do
+        if ! process_group_is_alive "$frontend_pid" && \
+            ! process_group_is_alive "$backend_pid"; then
+            break
+        fi
+        sleep 0.2
+    done
+
+    if process_group_is_alive "$frontend_pid"; then
+        kill -KILL -- "-$frontend_pid" 2>/dev/null || true
+    fi
+    if process_group_is_alive "$backend_pid"; then
+        kill -KILL -- "-$backend_pid" 2>/dev/null || true
+    fi
+
+    if [[ -n "$frontend_pid" ]]; then
+        wait "$frontend_pid" 2>/dev/null || true
+    fi
+    if [[ -n "$backend_pid" ]]; then
+        wait "$backend_pid" 2>/dev/null || true
+    fi
+
+    if [[ -n "$frontend_pid" || -n "$backend_pid" ]]; then
+        echo "智保通开发服务已停止。"
+    fi
+
+    return "$exit_status"
+}
+
+http_ready() {
+    local url="$1"
+    curl --fail --silent --show-error --max-time 2 "$url" >/dev/null 2>&1
+}
+
+wait_for_services() {
+    local deadline=$((SECONDS + 45))
+
+    while (( SECONDS < deadline )); do
+        kill -0 "$frontend_pid" 2>/dev/null || \
+            die "前端开发服务器启动失败,请检查上方 pnpm 输出。"
+        kill -0 "$backend_pid" 2>/dev/null || \
+            die "后端开发服务器启动失败,请检查上方 Uvicorn 输出。"
+
+        if http_ready "http://127.0.0.1:5173/" && \
+            http_ready "http://127.0.0.1:5174/" && \
+            http_ready "http://127.0.0.1:8000/docs"; then
+            return 0
+        fi
+
+        sleep 0.3
+    done
+
+    die "开发服务在 45 秒内未全部就绪,请检查上方启动日志。"
+}
+
+case "$run_for_seconds" in
+    ''|*[!0-9]*)
+        die "ZBT_DEV_RUN_SECONDS 必须是非负整数。"
+        ;;
+esac
+
+trap cleanup EXIT
+trap 'exit 130' INT
+trap 'exit 143' TERM
+trap 'exit 129' HUP
+
+require_command pnpm
+require_command uv
+require_command curl
+require_command lsof
+
+assert_port_available 5173 "H5"
+assert_port_available 5174 "管理后台"
+assert_port_available 8000 "后端 API"
+
+echo "正在启动 H5、管理后台和后端 API..."
+
+# Bash 3.2(macOS 系统自带版本)没有 setsid。临时启用 job control,
+# 可让两个后台任务各自成为独立进程组,清理时连同 Vite/Uvicorn 子进程一起结束。
+set -m
+(
+    cd "$project_root/frontend"
+    exec pnpm dev
+) &
+frontend_pid=$!
+
+(
+    cd "$project_root/backend"
+    exec uv run uvicorn zbt.main:app \
+        --host 127.0.0.1 \
+        --port 8000 \
+        --reload
+) &
+backend_pid=$!
+set +m
+
+wait_for_services
+
+echo
+echo "智保通开发服务已全部启动:"
+echo "H5:http://127.0.0.1:5173"
+echo "管理后台:http://127.0.0.1:5174"
+echo "后端 API:http://127.0.0.1:8000/docs"
+echo "按 Ctrl+C 可一次性停止全部服务。"
+
+ready_at=$SECONDS
+while true; do
+    kill -0 "$frontend_pid" 2>/dev/null || \
+        die "前端开发服务器意外退出,请检查上方日志。"
+    kill -0 "$backend_pid" 2>/dev/null || \
+        die "后端开发服务器意外退出,请检查上方日志。"
+
+    if (( run_for_seconds > 0 && SECONDS - ready_at >= run_for_seconds )); then
+        break
+    fi
+
+    sleep 0.5
+done

+ 62 - 6
scripts/init-databases.ps1

@@ -1,16 +1,72 @@
-param(
-    [string]$RootUser = "root"
+[CmdletBinding()]
+param(
+    [string]$ContainerName = "zhibaotong-mysql"
 )
 
 $ErrorActionPreference = "Stop"
 $ProjectRoot = Split-Path -Parent $PSScriptRoot
-Push-Location (Join-Path $ProjectRoot "backend")
+$SqlPath = Join-Path $ProjectRoot "database\mysql.sql"
+$ContainerSqlPath = "/tmp/zbt-init.sql"
+
+if (-not (Test-Path -LiteralPath $SqlPath)) {
+    throw "缺少数据库快照:$SqlPath"
+}
+
+if ($null -eq (Get-Command docker -ErrorAction SilentlyContinue)) {
+    throw "未找到 Docker 命令,请先安装并启动 Docker Desktop。"
+}
+
+docker inspect $ContainerName *> $null
+if ($LASTEXITCODE -ne 0) {
+    throw "未找到 MySQL 容器 $ContainerName。可通过 -ContainerName 指定实际容器名。"
+}
+
+$Running = docker inspect --format '{{.State.Running}}' $ContainerName
+if ($LASTEXITCODE -ne 0) {
+    throw "无法读取 MySQL 容器 $ContainerName 的运行状态。"
+}
+if ($Running.Trim() -ne "true") {
+    docker start $ContainerName | Out-Null
+    if ($LASTEXITCODE -ne 0) {
+        throw "MySQL 容器 $ContainerName 启动失败。"
+    }
+}
+
+# MySQL 首次创建容器时会先启动临时实例初始化系统表,随后再启动正式实例。
+# 要求连续多次响应,避免 SQL 恰好导入到临时实例与正式实例的切换窗口。
+$Ready = $false
+$ConsecutiveSuccesses = 0
+for ($Attempt = 1; $Attempt -le 90; $Attempt++) {
+    docker exec $ContainerName sh -c 'mysqladmin ping -uroot -p"$MYSQL_ROOT_PASSWORD" --silent >/dev/null 2>&1'
+    if ($LASTEXITCODE -eq 0) {
+        $ConsecutiveSuccesses++
+        if ($ConsecutiveSuccesses -ge 5) {
+            $Ready = $true
+            break
+        }
+    }
+    else {
+        $ConsecutiveSuccesses = 0
+    }
+    Start-Sleep -Seconds 1
+}
+if (-not $Ready) {
+    throw "MySQL 容器 $ContainerName 在 90 秒内未就绪。"
+}
+
 try {
-    uv run python -m zbt.commands.init_databases --root-user $RootUser
+    docker cp $SqlPath "${ContainerName}:$ContainerSqlPath"
     if ($LASTEXITCODE -ne 0) {
-        throw "数据库初始化失败。"
+        throw "数据库快照复制到容器失败。"
     }
+
+    docker exec $ContainerName sh -c 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" --default-character-set=utf8mb4 < /tmp/zbt-init.sql'
+    if ($LASTEXITCODE -ne 0) {
+        throw "数据库快照导入失败,请检查容器中的 MYSQL_ROOT_PASSWORD。"
+    }
+
+    Write-Host "第二阶段 MySQL 数据初始化完成。" -ForegroundColor Green
 }
 finally {
-    Pop-Location
+    docker exec $ContainerName rm -f $ContainerSqlPath *> $null
 }

+ 57 - 0
scripts/init-databases.sh

@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+sql_path="${project_root}/database/mysql.sql"
+container_name="${1:-${ZBT_MYSQL_CONTAINER:-zhibaotong-mysql}}"
+container_sql_path="/tmp/zbt-init.sql"
+
+[[ -f "$sql_path" ]] || {
+  echo "错误:缺少数据库快照 $sql_path" >&2
+  exit 1
+}
+command -v docker >/dev/null 2>&1 || {
+  echo "错误:未找到 Docker 命令,请先安装并启动 Docker Desktop。" >&2
+  exit 1
+}
+docker inspect "$container_name" >/dev/null 2>&1 || {
+  echo "错误:未找到 MySQL 容器 $container_name,可将实际容器名作为第一个参数传入。" >&2
+  exit 1
+}
+
+if [[ "$(docker inspect --format '{{.State.Running}}' "$container_name")" != "true" ]]; then
+  docker start "$container_name" >/dev/null
+fi
+
+# 首次创建 MySQL 容器时会经历“临时实例初始化 -> 正式实例启动”。
+# 要求连续多次响应,避免命令落在两次启动之间。
+ready=false
+consecutive_successes=0
+for _ in $(seq 1 90); do
+  if docker exec "$container_name" sh -c 'mysqladmin ping -uroot -p"$MYSQL_ROOT_PASSWORD" --silent >/dev/null 2>&1'; then
+    consecutive_successes=$((consecutive_successes + 1))
+    if (( consecutive_successes >= 5 )); then
+      ready=true
+      break
+    fi
+  else
+    consecutive_successes=0
+  fi
+  sleep 1
+done
+
+if [[ "$ready" != "true" ]]; then
+  echo "MySQL 容器 $container_name 在 90 秒内未就绪。" >&2
+  exit 1
+fi
+
+cleanup() {
+  docker exec "$container_name" rm -f "$container_sql_path" >/dev/null 2>&1 || true
+}
+trap cleanup EXIT
+
+docker cp "$sql_path" "${container_name}:${container_sql_path}"
+docker exec "$container_name" sh -c \
+  'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" --default-character-set=utf8mb4 < /tmp/zbt-init.sql'
+
+echo "第二阶段 MySQL 数据初始化完成。"

+ 6 - 0
scripts/seed-knowledge.sh

@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$project_root/backend"
+uv run python -m zbt.commands.seed_knowledge

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است