# 一次性:将旧 pg_dump 数据迁入当前 Alembic schema(api/scripts/migrate_legacy_to_current.py) # # 目标库须已是 alembic upgrade head(与线上一致);占号用户清理逻辑依赖当前全部迁移后的表结构。 # # 不会在 push / 部署时自动运行,仅手动 workflow_dispatch,避免每次构建误迁库。 # 远端需已用 docker compose 部署(目录约定与 docker-build-deploy 一致:DEPLOY_PATH / PROD_DEPLOY_PATH)。 # # 备份文件:提交在仓库 api/backups/(默认 life_echo_20260313_182756.sql), # workflow 会先 scp 到远端再迁移。其他 *.sql 仍被 gitignore,需按需增加 ! 例外行。 # # Secrets:与 Docker Build and Deploy 相同(staging:无前缀 SSH_* / DEPLOY_PATH;production:PROD_*)。 name: Legacy DB migrate (one-shot) on: workflow_dispatch: inputs: environment: description: 'staging 或 production' required: true type: choice options: - staging - production confirm: description: '确认执行迁移(必须完整输入 MIGRATE)' required: true type: string dump_filename: description: '仓库 api/backups/ 与远端同名 SQL 文件(须已提交)' required: true type: string default: life_echo_20260313_182756.sql recreate_legacy_database: description: 'true=DROP 并重建 life_echo_legacy + 重新导入 SQL;false=仅运行迁移脚本' required: true type: boolean default: true concurrency: group: legacy-migrate-${{ github.ref }} cancel-in-progress: false env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: migrate: name: Legacy migrate on server runs-on: ubuntu-latest steps: - name: Require confirm MIGRATE run: | if [ "${{ github.event.inputs.confirm }}" != "MIGRATE" ]; then echo "::error::确认字段必须精确为 MIGRATE,当前未执行任何操作" exit 1 fi - name: Checkout uses: actions/checkout@v5 - name: Verify backup file in repo env: DUMP_FILENAME: ${{ github.event.inputs.dump_filename }} run: | set -euo pipefail f="api/backups/$DUMP_FILENAME" if [ ! -f "$f" ]; then echo "::error::仓库中不存在 $f,请提交该文件或改正 dump_filename,并在 .gitignore 中为该文件增加 ! 例外" exit 1 fi ls -la "$f" - name: Set up SSH (production) if: github.event.inputs.environment == 'production' uses: webfactory/ssh-agent@v0.9.1 with: ssh-private-key: ${{ secrets.PROD_SSH_PRIVATE_KEY }} - name: Set up SSH (staging) if: github.event.inputs.environment != 'production' uses: webfactory/ssh-agent@v0.9.1 with: ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} - name: Export deploy connection env run: | if [ "${{ github.event.inputs.environment }}" = "production" ]; then { echo "SSH_HOST=${{ secrets.PROD_SSH_HOST }}" echo "SSH_USER=${{ secrets.PROD_SSH_USER }}" echo "SSH_PORT=${{ secrets.PROD_SSH_PORT || '22' }}" echo "COMPOSE_DIR=${{ secrets.PROD_DEPLOY_PATH || '/opt/life-echo' }}" } >> "$GITHUB_ENV" else { echo "SSH_HOST=${{ secrets.SSH_HOST }}" echo "SSH_USER=${{ secrets.SSH_USER }}" echo "SSH_PORT=${{ secrets.SSH_PORT || '22' }}" echo "COMPOSE_DIR=${{ secrets.DEPLOY_PATH || '/opt/life-echo' }}" } >> "$GITHUB_ENV" fi - name: Add server to known hosts run: | mkdir -p ~/.ssh ssh-keyscan -H -p "${SSH_PORT:-22}" "${SSH_HOST}" >> ~/.ssh/known_hosts - name: Upload dump to server env: DUMP_FILENAME: ${{ github.event.inputs.dump_filename }} run: | set -euo pipefail ssh -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" "mkdir -p \"$COMPOSE_DIR/api/backups\"" scp -P "$SSH_PORT" "api/backups/$DUMP_FILENAME" "$SSH_USER@$SSH_HOST:$COMPOSE_DIR/api/backups/$DUMP_FILENAME" echo "已上传 api/backups/$DUMP_FILENAME -> $COMPOSE_DIR/api/backups/" - name: Run legacy migrate on remote env: DUMP_FILENAME: ${{ github.event.inputs.dump_filename }} RECREATE_LEGACY: ${{ github.event.inputs.recreate_legacy_database }} run: | set -euo pipefail # 通过环境变量传入远端,避免 heredoc 引号问题 ssh -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" \ "export COMPOSE_DIR=$(printf '%q' "$COMPOSE_DIR"); export DUMP_FILENAME=$(printf '%q' "$DUMP_FILENAME"); export RECREATE_LEGACY=$(printf '%q' "$RECREATE_LEGACY"); bash -s" << 'REMOTE' set -euo pipefail cd "$COMPOSE_DIR/api" DUMP="$PWD/backups/$DUMP_FILENAME" if [ ! -f "$DUMP" ]; then echo "::error::远端缺少备份文件: $DUMP" exit 1 fi if [ ! -f .env ]; then echo "::error::缺少 $PWD/.env" exit 1 fi # DATABASE_URL 可能含特殊字符,取第一个等号后的整行 DB_URL=$(grep -m1 '^DATABASE_URL=' .env | cut -d= -f2- | tr -d '\r' | sed 's/^"//;s/"$//;s/^'"'"'//;s/'"'"'$//') PG_USER=$(grep -m1 '^POSTGRES_USER=' .env 2>/dev/null | cut -d= -f2- | tr -d '\r' | sed 's/^"//;s/"$//' || true) PG_PASS=$(grep -m1 '^POSTGRES_PASSWORD=' .env 2>/dev/null | cut -d= -f2- | tr -d '\r' | sed 's/^"//;s/"$//' || true) PG_USER=${PG_USER:-postgres} PG_PASS=${PG_PASS:-postgres} LEGACY_URL="postgresql://${PG_USER}:${PG_PASS}@postgres:5432/life_echo_legacy" if [ "$RECREATE_LEGACY" = "true" ]; then echo "重建 life_echo_legacy 并导入 SQL..." docker compose exec -T postgres psql -U postgres -d postgres -v ON_ERROR_STOP=1 -c \ "DROP DATABASE IF EXISTS life_echo_legacy WITH (FORCE);" docker compose exec -T postgres psql -U postgres -d postgres -v ON_ERROR_STOP=1 -c \ "CREATE DATABASE life_echo_legacy;" docker exec -i life-echo-postgres psql -U postgres -d life_echo_legacy -v ON_ERROR_STOP=1 < "$DUMP" else echo "跳过 DROP/导入,假定 life_echo_legacy 已就绪" fi echo "执行 Python 迁移(api 容器内)..." docker compose exec -T api uv run python scripts/migrate_legacy_to_current.py \ --legacy-url "$LEGACY_URL" \ --target-url "$DB_URL" \ --phone-conflict replace_target echo "完成。" REMOTE