From ecaae178b6b73ecceca3a9edf3e8662eee742337 Mon Sep 17 00:00:00 2001 From: sagit Date: Thu, 12 Feb 2026 08:02:23 +0000 Subject: [PATCH] feat(postgres): add migration workflow and id self-heal --- README.md | 37 ++- doc/faq.md | 2 +- doc/install.md | 2 + doc/postgresql.md | 44 ++- .../internal/store/sqlite/repository.go | 156 ++++++++++ panel_install.sh | 268 +++++++++++++++--- 6 files changed, 446 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 9f92e44..ce0fee1 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/install. #### PostgreSQL 部署(Docker Compose) -`docker-compose-v4.yml` / `docker-compose-v6.yml` 已包含 PostgreSQL 服务。默认仍使用 SQLite,切换到 PostgreSQL 只需要配置环境变量。 +安装脚本会根据环境自动下载对应的 Compose 配置并保存为 `docker-compose.yml`。默认仍使用 SQLite,切换到 PostgreSQL 只需要配置环境变量。 1) 在 `docker-compose` 同目录创建或修改 `.env`: @@ -61,47 +61,56 @@ POSTGRES_USER=flux_panel POSTGRES_PASSWORD=replace_with_strong_password ``` -2) 启动(IPv4/IPv6 二选一): +> 📌 使用安装脚本部署时,`POSTGRES_PASSWORD` 会自动随机生成并写入 `.env`。 + +2) 启动服务: ```bash -docker compose -f docker-compose-v4.yml up -d -``` - -```bash -docker compose -f docker-compose-v6.yml up -d +docker compose up -d ``` 3) 如果你想继续使用 SQLite,保留 `DB_TYPE=sqlite`(或不设置 `DB_TYPE`)即可。 #### 从 SQLite 迁移到 PostgreSQL -以下示例基于 Docker Volume `sqlite_data`(项目默认配置)与 `pgloader`: +如果你是通过 `panel_install.sh` 安装面板,推荐直接使用脚本菜单一键迁移: + +```bash +./panel_install.sh +# 选择 4. 迁移到 PostgreSQL +``` + +脚本会自动完成 SQLite 备份、PostgreSQL 启动、`pgloader` 导入、`.env` 中 `DB_TYPE`/`DATABASE_URL` 更新,并重启服务。 + +如果你希望手动迁移,以下示例基于 Docker Volume `sqlite_data`(项目默认配置)与 `pgloader`: 1) 停止服务并备份 SQLite 数据: ```bash -docker compose -f docker-compose-v4.yml down +docker compose down docker run --rm -v sqlite_data:/data -v "$(pwd)":/backup alpine sh -c "cp /data/gost.db /backup/gost.db.bak" ``` 2) 仅启动 PostgreSQL: ```bash -docker compose -f docker-compose-v4.yml up -d postgres +docker compose up -d postgres ``` 3) 使用 `pgloader` 迁移: ```bash -docker run --rm --network gost-network -v sqlite_data:/sqlite dimitri/pgloader:latest pgloader /sqlite/gost.db postgresql://flux_panel:replace_with_strong_password@postgres:5432/flux_panel +source .env +docker run --rm --network gost-network -v sqlite_data:/sqlite dimitri/pgloader:latest pgloader /sqlite/gost.db "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}" ``` 4) 切换后端到 PostgreSQL 并启动: ```bash +source .env export DB_TYPE=postgres -export DATABASE_URL="postgres://flux_panel:replace_with_strong_password@postgres:5432/flux_panel?sslmode=disable" -docker compose -f docker-compose-v4.yml up -d +export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable" +docker compose up -d ``` 5) 迁移完成后,登录面板检查用户、隧道、转发、节点数据是否正确。 @@ -138,7 +147,7 @@ The following major changes and additions have been made in this fork (FLVX): - **Removed**: `ios-app/` - Source code for the iOS client. ### 5. Infrastructure & Scripts -- **Modified**: `docker-compose-v4.yml`, `docker-compose-v6.yml` (Updated for Go backend). +- **Modified**: `docker-compose.yml` (installer output name, auto-selects IPv4/IPv6 template, updated for Go backend). - **Modified**: `install.sh`, `panel_install.sh` (Updated installation logic). - **Added**: `AGENTS.md` (Project documentation). diff --git a/doc/faq.md b/doc/faq.md index 0799e9a..54b763a 100644 --- a/doc/faq.md +++ b/doc/faq.md @@ -24,7 +24,7 @@ **A**: 面板安装脚本会自动尝试配置 Docker 的 IPv6。如果失败,请手动检查 `/etc/docker/daemon.json` 配置,确保 `ipv6: true` 且分配了正确的 `fixed-cidr-v6` 子网。 ### Q6: 如何切换到 PostgreSQL? -**A**: 在 `.env` 文件中设置 `DB_TYPE=postgres` 并填写相关配置,然后重启服务即可。各变量默认值:`DATABASE_URL=postgres://flux_panel:replace_with_strong_password@postgres:5432/flux_panel?sslmode=disable`、`POSTGRES_DB=flux_panel`、`POSTGRES_USER=flux_panel`,`POSTGRES_PASSWORD` 需自行设置强密码。详见 [PostgreSQL 数据库指南](./postgresql.md)。 +**A**: 在 `.env` 文件中设置 `DB_TYPE=postgres`,并让 `DATABASE_URL` 与 `POSTGRES_*` 保持一致,然后执行 `docker compose up -d` 重启服务即可。使用安装脚本部署时,`POSTGRES_PASSWORD` 会自动随机生成并写入 `.env`。详见 [PostgreSQL 数据库指南](./postgresql.md)。 ### Q7: 从 SQLite 迁移到 PostgreSQL 后数据丢失? **A**: diff --git a/doc/install.md b/doc/install.md index b6bd507..3067103 100644 --- a/doc/install.md +++ b/doc/install.md @@ -41,6 +41,8 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/panel_instal 1. 安装面板 2. 更新面板 3. 卸载面板 +4. 迁移到 PostgreSQL +5. 退出 --- diff --git a/doc/postgresql.md b/doc/postgresql.md index 282c626..6a2965d 100644 --- a/doc/postgresql.md +++ b/doc/postgresql.md @@ -42,7 +42,7 @@ FLVX 默认使用 SQLite 作为数据库,同时也内置了对 PostgreSQL 的 ## 三、全新部署(Docker Compose + PostgreSQL) -`docker-compose-v4.yml` / `docker-compose-v6.yml` 已包含 PostgreSQL 服务,默认使用 SQLite。只需配置环境变量即可切换到 PostgreSQL。 +安装脚本会根据环境自动下载对应的 Compose 配置并保存为 `docker-compose.yml`。默认使用 SQLite,只需配置环境变量即可切换到 PostgreSQL。 ### 1. 创建 `.env` 文件 @@ -67,14 +67,8 @@ POSTGRES_PASSWORD=替换为强密码 ### 2. 启动服务 -IPv4 环境: ```bash -docker compose -f docker-compose-v4.yml up -d -``` - -IPv6 环境: -```bash -docker compose -f docker-compose-v6.yml up -d +docker compose up -d ``` ### 3. 验证 @@ -96,11 +90,29 @@ docker logs flux-panel-postgres 如果你已经在使用 SQLite 并且希望迁移到 PostgreSQL,请按照以下步骤操作。 +### 快速方式:脚本菜单一键迁移(推荐) + +如果你是通过安装脚本部署面板,可直接执行: + +```bash +./panel_install.sh +# 选择 4. 迁移到 PostgreSQL +``` + +脚本会自动完成以下操作: +- 备份 SQLite 数据到当前目录(`gost.db.bak`) +- 启动并等待 PostgreSQL 健康检查通过 +- 使用 `pgloader` 导入 SQLite 数据 +- 自动写入 `.env` 的 `DB_TYPE=postgres` 与 `DATABASE_URL` +- 重启服务并等待后端健康检查 + +### 手动方式:按步骤迁移 + ### 1. 备份 SQLite 数据 ```bash # 停止所有服务 -docker compose -f docker-compose-v4.yml down +docker compose down # 备份 SQLite 数据文件到当前目录 docker run --rm -v sqlite_data:/data -v "$(pwd)":/backup alpine sh -c "cp /data/gost.db /backup/gost.db.bak" @@ -113,7 +125,7 @@ docker run --rm -v sqlite_data:/data -v "$(pwd)":/backup alpine sh -c "cp /data/ ### 3. 仅启动 PostgreSQL ```bash -docker compose -f docker-compose-v4.yml up -d postgres +docker compose up -d postgres ``` 等待 PostgreSQL 完全就绪: @@ -127,19 +139,23 @@ docker inspect --format='{{.State.Health.Status}}' flux-panel-postgres ### 4. 使用 pgloader 迁移数据 ```bash +source .env docker run --rm \ --network gost-network \ -v sqlite_data:/sqlite \ dimitri/pgloader:latest \ - pgloader /sqlite/gost.db postgresql://flux_panel:你的密码@postgres:5432/flux_panel + pgloader /sqlite/gost.db "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}" ``` -> 📌 将 `你的密码` 替换为实际的 `POSTGRES_PASSWORD`。 +> 📌 建议直接从 `.env` 读取 `POSTGRES_USER`、`POSTGRES_PASSWORD`、`POSTGRES_DB`,避免手填密码导致认证失败。 ### 5. 启动全部服务 ```bash -docker compose -f docker-compose-v4.yml up -d +source .env +export DB_TYPE=postgres +export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable" +docker compose up -d ``` ### 6. 验证迁移 @@ -182,7 +198,7 @@ DATABASE_URL=postgres://flux_panel:你的强密码@数据库地址:5432/flux_pan 如果使用外部 PostgreSQL,可以在启动时不启动内置的 postgres 服务: ```bash -docker compose -f docker-compose-v4.yml up -d backend frontend +docker compose up -d backend frontend ``` --- diff --git a/go-backend/internal/store/sqlite/repository.go b/go-backend/internal/store/sqlite/repository.go index 5de6f2b..c3adea4 100644 --- a/go-backend/internal/store/sqlite/repository.go +++ b/go-backend/internal/store/sqlite/repository.go @@ -1345,9 +1345,165 @@ func migrateSchema(db *store.DB) error { ensureColumn(table, col, typ) } } + + if db.Dialect() == store.DialectPostgres { + if err := ensurePostgresIDDefaults(db); err != nil { + return err + } + } return nil } +func ensurePostgresIDDefaults(db *store.DB) error { + rows, err := db.Query(` + SELECT c.table_schema, c.table_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + JOIN information_schema.columns c + ON c.table_schema = kcu.table_schema + AND c.table_name = kcu.table_name + AND c.column_name = kcu.column_name + WHERE tc.constraint_type = 'PRIMARY KEY' + AND kcu.column_name = 'id' + AND c.data_type IN ('integer', 'bigint') + AND c.is_identity = 'NO' + AND c.table_schema = current_schema() + ORDER BY c.table_name ASC + `) + if err != nil { + return fmt.Errorf("discover postgres id columns: %w", err) + } + defer rows.Close() + + for rows.Next() { + var schemaName string + var tableName string + if err := rows.Scan(&schemaName, &tableName); err != nil { + return fmt.Errorf("scan postgres id table row: %w", err) + } + if err := ensurePostgresTableIDDefault(db, schemaName, tableName); err != nil { + return fmt.Errorf("repair %s.%s id default: %w", schemaName, tableName, err) + } + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterate postgres id tables: %w", err) + } + + return nil +} + +func ensurePostgresTableIDDefault(db *store.DB, schemaName, tableName string) error { + var defaultExpr sql.NullString + if err := db.QueryRow(` + SELECT column_default + FROM information_schema.columns + WHERE table_schema = ? + AND table_name = ? + AND column_name = 'id' + LIMIT 1 + `, schemaName, tableName).Scan(&defaultExpr); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return err + } + + hasNextvalDefault := defaultExpr.Valid && strings.Contains(strings.ToLower(defaultExpr.String), "nextval(") + + var serialSeq sql.NullString + if err := db.QueryRow(` + SELECT pg_get_serial_sequence(format('%I.%I', ?, ?), 'id') + `, schemaName, tableName).Scan(&serialSeq); err != nil { + return err + } + + seqRef := strings.TrimSpace(serialSeq.String) + if seqRef == "" && hasNextvalDefault { + seqRef = extractNextvalRegclass(defaultExpr.String) + } + + if !hasNextvalDefault || seqRef == "" { + seqName := tableName + "_id_seq" + if _, err := db.Exec(fmt.Sprintf("CREATE SEQUENCE IF NOT EXISTS %s.%s", quoteSQLIdentifier(schemaName), quoteSQLIdentifier(seqName))); err != nil { + return err + } + + seqRef = schemaName + "." + seqName + if _, err := db.Exec(fmt.Sprintf( + "ALTER TABLE %s.%s ALTER COLUMN id SET DEFAULT nextval(%s::regclass)", + quoteSQLIdentifier(schemaName), + quoteSQLIdentifier(tableName), + quoteSQLLiteral(seqRef), + )); err != nil { + return err + } + + if _, err := db.Exec(fmt.Sprintf( + "ALTER SEQUENCE %s.%s OWNED BY %s.%s.id", + quoteSQLIdentifier(schemaName), + quoteSQLIdentifier(seqName), + quoteSQLIdentifier(schemaName), + quoteSQLIdentifier(tableName), + )); err != nil { + return err + } + } + + return syncPostgresTableIDSequence(db, schemaName, tableName, seqRef) +} + +func syncPostgresTableIDSequence(db *store.DB, schemaName, tableName, seqRef string) error { + var maxID int64 + if err := db.QueryRow(fmt.Sprintf( + "SELECT COALESCE(MAX(id), 0) FROM %s.%s", + quoteSQLIdentifier(schemaName), + quoteSQLIdentifier(tableName), + )).Scan(&maxID); err != nil { + return err + } + + setVal := maxID + isCalled := true + if maxID <= 0 { + setVal = 1 + isCalled = false + } + + if _, err := db.Exec(`SELECT setval(?::regclass, ?, ?)`, seqRef, setVal, isCalled); err != nil { + return err + } + + return nil +} + +func extractNextvalRegclass(defaultExpr string) string { + nextvalIdx := strings.Index(strings.ToLower(defaultExpr), "nextval(") + if nextvalIdx < 0 { + return "" + } + expr := defaultExpr[nextvalIdx:] + firstQuote := strings.Index(expr, "'") + if firstQuote < 0 { + return "" + } + expr = expr[firstQuote+1:] + secondQuote := strings.Index(expr, "'") + if secondQuote < 0 { + return "" + } + return strings.TrimSpace(expr[:secondQuote]) +} + +func quoteSQLIdentifier(ident string) string { + return `"` + strings.ReplaceAll(ident, `"`, `""`) + `"` +} + +func quoteSQLLiteral(value string) string { + return "'" + strings.ReplaceAll(value, "'", "''") + "'" +} + func isMissingColumnError(dialect store.Dialect, err error) bool { if err == nil { return false diff --git a/panel_install.sh b/panel_install.sh index 4a76f76..83d7bc6 100755 --- a/panel_install.sh +++ b/panel_install.sh @@ -203,7 +203,8 @@ show_menu() { echo "1. 安装面板" echo "2. 更新面板" echo "3. 卸载面板" - echo "4. 退出" + echo "4. 迁移到 PostgreSQL" + echo "5. 退出" echo "===============================================" } @@ -232,6 +233,90 @@ upsert_env_var() { mv "$tmp_file" "$file" } +get_env_var() { + local key="$1" + local file="${2:-.env}" + + if [[ ! -f "$file" ]]; then + return 0 + fi + + grep -m1 "^${key}=" "$file" | cut -d= -f2- +} + +get_current_db_type() { + local db_type database_url + + db_type=$(get_env_var "DB_TYPE") + database_url=$(get_env_var "DATABASE_URL") + + if [[ "$db_type" == "postgres" || "$database_url" == postgres://* || "$database_url" == postgresql://* ]]; then + echo "postgres" + else + echo "sqlite" + fi +} + +wait_for_postgres_healthy() { + local pg_health + + echo "🔍 检查 PostgreSQL 服务状态..." + for i in {1..90}; do + if docker ps --format "{{.Names}}" | grep -q "^flux-panel-postgres$"; then + pg_health=$(docker inspect -f '{{.State.Health.Status}}' flux-panel-postgres 2>/dev/null || echo "unknown") + if [[ "$pg_health" == "healthy" ]]; then + echo "✅ PostgreSQL 服务健康检查通过" + return 0 + elif [[ "$pg_health" == "unhealthy" ]]; then + echo "⚠️ PostgreSQL 健康状态:$pg_health" + fi + else + pg_health="not_running" + fi + + if [ $i -eq 90 ]; then + echo "❌ PostgreSQL 启动超时(90秒)" + echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' flux-panel-postgres 2>/dev/null || echo '容器不存在')" + return 1 + fi + + if [ $((i % 15)) -eq 1 ]; then + echo "⏳ 等待 PostgreSQL 启动... ($i/90) 状态:${pg_health:-unknown}" + fi + sleep 1 + done +} + +wait_for_backend_healthy() { + local backend_health + + echo "🔍 检查后端服务状态..." + for i in {1..90}; do + if docker ps --format "{{.Names}}" | grep -q "^flux-panel-backend$"; then + backend_health=$(docker inspect -f '{{.State.Health.Status}}' flux-panel-backend 2>/dev/null || echo "unknown") + if [[ "$backend_health" == "healthy" ]]; then + echo "✅ 后端服务健康检查通过" + return 0 + elif [[ "$backend_health" == "unhealthy" ]]; then + echo "⚠️ 后端健康状态:$backend_health" + fi + else + backend_health="not_running" + fi + + if [ $i -eq 90 ]; then + echo "❌ 后端服务启动超时(90秒)" + echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' flux-panel-backend 2>/dev/null || echo '容器不存在')" + return 1 + fi + + if [ $((i % 15)) -eq 1 ]; then + echo "⏳ 等待后端服务启动... ($i/90) 状态:${backend_health:-unknown}" + fi + sleep 1 + done +} + # 删除脚本自身 delete_self() { echo "" @@ -253,6 +338,33 @@ get_config_params() { read -p "后端端口(默认 6365): " BACKEND_PORT BACKEND_PORT=${BACKEND_PORT:-6365} + echo "请选择数据库类型:" + echo "1. SQLite(默认)" + echo "2. PostgreSQL" + read -p "数据库类型(1/2,默认 1): " DB_CHOICE + case "$DB_CHOICE" in + 2) + DB_TYPE="postgres" + ;; + ""|1) + DB_TYPE="sqlite" + ;; + *) + echo "⚠️ 输入无效,默认使用 SQLite" + DB_TYPE="sqlite" + ;; + esac + + POSTGRES_DB="flux_panel" + POSTGRES_USER="flux_panel" + POSTGRES_PASSWORD=$(generate_random) + + if [[ "$DB_TYPE" == "postgres" ]]; then + DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable" + else + DATABASE_URL="" + fi + # 生成JWT密钥 JWT_SECRET=$(generate_random) } @@ -280,10 +392,23 @@ JWT_SECRET=$JWT_SECRET FRONTEND_PORT=$FRONTEND_PORT BACKEND_PORT=$BACKEND_PORT FLUX_VERSION=$RESOLVED_VERSION + +DB_TYPE=$DB_TYPE +DATABASE_URL=$DATABASE_URL + +POSTGRES_DB=$POSTGRES_DB +POSTGRES_USER=$POSTGRES_USER +POSTGRES_PASSWORD=$POSTGRES_PASSWORD EOF echo "🚀 启动 docker 服务..." - $DOCKER_CMD up -d + if [[ "$DB_TYPE" == "postgres" ]]; then + $DOCKER_CMD up -d postgres + wait_for_postgres_healthy + $DOCKER_CMD up -d backend frontend + else + $DOCKER_CMD up -d backend frontend + fi echo "🎉 部署完成" echo "🌐 访问地址: http://服务器IP:$FRONTEND_PORT" @@ -300,6 +425,12 @@ update_panel() { echo "🔄 开始更新面板..." check_docker + if [[ ! -f ".env" ]]; then + echo "⚠️ 未找到 .env,默认按 SQLite 模式更新" + fi + CURRENT_DB_TYPE=$(get_current_db_type) + echo "🗄️ 当前数据库类型:$CURRENT_DB_TYPE" + echo "🔍 获取最新版本号..." LATEST_VERSION=$(resolve_latest_release_tag) || { echo "❌ 无法获取最新版本号,更新终止" @@ -333,49 +464,113 @@ update_panel() { $DOCKER_CMD down echo "⬇️ 拉取最新镜像..." - $DOCKER_CMD pull + if [[ "$CURRENT_DB_TYPE" == "postgres" ]]; then + $DOCKER_CMD pull backend frontend postgres + else + $DOCKER_CMD pull backend frontend + fi echo "🚀 启动更新后的服务..." - $DOCKER_CMD up -d + if [[ "$CURRENT_DB_TYPE" == "postgres" ]]; then + $DOCKER_CMD up -d postgres + wait_for_postgres_healthy + $DOCKER_CMD up -d backend frontend + else + $DOCKER_CMD up -d backend frontend + fi # 等待服务启动 echo "⏳ 等待服务启动..." - # 检查后端容器健康状态 - echo "🔍 检查后端服务状态..." - for i in {1..90}; do - if docker ps --format "{{.Names}}" | grep -q "^flux-panel-backend$"; then - BACKEND_HEALTH=$(docker inspect -f '{{.State.Health.Status}}' flux-panel-backend 2>/dev/null || echo "unknown") - if [[ "$BACKEND_HEALTH" == "healthy" ]]; then - echo "✅ 后端服务健康检查通过" - break - elif [[ "$BACKEND_HEALTH" == "starting" ]]; then - # 继续等待 - : - elif [[ "$BACKEND_HEALTH" == "unhealthy" ]]; then - echo "⚠️ 后端健康状态:$BACKEND_HEALTH" - fi - else - echo "⚠️ 后端容器未找到或未运行" - BACKEND_HEALTH="not_running" - fi - if [ $i -eq 90 ]; then - echo "❌ 后端服务启动超时(90秒)" - echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' flux-panel-backend 2>/dev/null || echo '容器不存在')" - echo "🛑 更新终止" - return 1 - fi - # 每15秒显示一次进度 - if [ $((i % 15)) -eq 1 ]; then - echo "⏳ 等待后端服务启动... ($i/90) 状态:${BACKEND_HEALTH:-unknown}" - fi - sleep 1 - done + if ! wait_for_backend_healthy; then + echo "🛑 更新终止" + return 1 + fi echo "✅ 更新完成" } +migrate_to_postgres() { + local current_db_type postgres_db postgres_user postgres_password database_url + + echo "🔄 开始迁移 SQLite -> PostgreSQL..." + check_docker + + if [[ ! -f ".env" ]]; then + echo "❌ 未找到 .env 文件,请先安装面板" + return 1 + fi + + if [[ ! -f "docker-compose.yml" ]]; then + echo "⚠️ 未找到 docker-compose.yml 文件,正在下载..." + DOCKER_COMPOSE_URL=$(get_docker_compose_url) + echo "📡 选择配置文件:$(basename "$DOCKER_COMPOSE_URL")" + curl -L -o docker-compose.yml "$DOCKER_COMPOSE_URL" + echo "✅ docker-compose.yml 下载完成" + fi + + current_db_type=$(get_current_db_type) + if [[ "$current_db_type" == "postgres" ]]; then + echo "ℹ️ 当前已使用 PostgreSQL,无需迁移" + return 0 + fi + + postgres_db=$(get_env_var "POSTGRES_DB") + postgres_user=$(get_env_var "POSTGRES_USER") + postgres_password=$(get_env_var "POSTGRES_PASSWORD") + + postgres_db=${postgres_db:-flux_panel} + postgres_user=${postgres_user:-flux_panel} + postgres_password=${postgres_password:-$(generate_random)} + + upsert_env_var ".env" "POSTGRES_DB" "$postgres_db" + upsert_env_var ".env" "POSTGRES_USER" "$postgres_user" + upsert_env_var ".env" "POSTGRES_PASSWORD" "$postgres_password" + + echo "🛑 停止当前服务..." + docker stop -t 30 flux-panel-backend 2>/dev/null || true + docker stop -t 10 vite-frontend 2>/dev/null || true + echo "⏳ 等待数据同步..." + sleep 5 + $DOCKER_CMD down + + echo "💾 备份 SQLite 数据到当前目录..." + if ! docker run --rm -v sqlite_data:/data -v "$(pwd)":/backup alpine sh -c "cp /data/gost.db /backup/gost.db.bak"; then + echo "❌ SQLite 备份失败,迁移终止" + return 1 + fi + + echo "🚀 启动 PostgreSQL..." + $DOCKER_CMD up -d postgres + if ! wait_for_postgres_healthy; then + echo "🛑 PostgreSQL 未就绪,迁移终止" + return 1 + fi + + echo "🔄 执行 pgloader 迁移..." + if ! docker run --rm --network gost-network -v sqlite_data:/sqlite dimitri/pgloader:latest pgloader /sqlite/gost.db "postgresql://${postgres_user}:${postgres_password}@postgres:5432/${postgres_db}"; then + echo "❌ pgloader 迁移失败,迁移终止(如报 28P01,可执行 docker volume rm postgres_data 后重试)" + return 1 + fi + + database_url="postgresql://${postgres_user}:${postgres_password}@postgres:5432/${postgres_db}?sslmode=disable" + upsert_env_var ".env" "DB_TYPE" "postgres" + upsert_env_var ".env" "DATABASE_URL" "$database_url" + + echo "🚀 启动迁移后的服务..." + $DOCKER_CMD up -d postgres backend frontend + + echo "⏳ 等待服务启动..." + if ! wait_for_backend_healthy; then + echo "🛑 迁移后服务启动失败" + return 1 + fi + + echo "✅ SQLite -> PostgreSQL 迁移完成" +} + + # 卸载功能 uninstall_panel() { @@ -428,6 +623,11 @@ main() { exit 0 ;; 4) + migrate_to_postgres + delete_self + exit 0 + ;; + 5) echo "👋 退出脚本" delete_self exit 0