diff --git a/.gitea/workflows/secrets.yml b/.gitea/workflows/secrets.yml index bd96ec0..869b6de 100644 --- a/.gitea/workflows/secrets.yml +++ b/.gitea/workflows/secrets.yml @@ -208,6 +208,7 @@ jobs: DEPLOY_HOST: ${{ vars.DEPLOY_HOST }} DEPLOY_USER: ${{ vars.DEPLOY_USER }} DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + DEPLOY_KNOWN_HOSTS: ${{ vars.DEPLOY_KNOWN_HOSTS }} run: | if [ -z "$DEPLOY_HOST" ] || [ -z "$DEPLOY_USER" ] || [ -z "$DEPLOY_SSH_KEY" ]; then echo "部署跳过:请配置 vars.DEPLOY_HOST、vars.DEPLOY_USER 与 secrets.DEPLOY_SSH_KEY" @@ -216,19 +217,26 @@ jobs: echo "$DEPLOY_SSH_KEY" > /tmp/deploy_key chmod 600 /tmp/deploy_key + trap 'rm -f /tmp/deploy_key' EXIT - scp -i /tmp/deploy_key -o StrictHostKeyChecking=no \ + if [ -n "$DEPLOY_KNOWN_HOSTS" ]; then + echo "$DEPLOY_KNOWN_HOSTS" > /tmp/deploy_known_hosts + ssh_opts="-o UserKnownHostsFile=/tmp/deploy_known_hosts -o StrictHostKeyChecking=yes" + else + ssh_opts="-o StrictHostKeyChecking=accept-new" + fi + + scp -i /tmp/deploy_key $ssh_opts \ "/tmp/artifact/${MCP_BINARY}" \ "${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/secrets-mcp.new" - ssh -i /tmp/deploy_key -o StrictHostKeyChecking=no "${DEPLOY_USER}@${DEPLOY_HOST}" " + ssh -i /tmp/deploy_key $ssh_opts "${DEPLOY_USER}@${DEPLOY_HOST}" " sudo mv /tmp/secrets-mcp.new /opt/secrets-mcp/secrets-mcp sudo chmod +x /opt/secrets-mcp/secrets-mcp sudo systemctl restart secrets-mcp sleep 2 sudo systemctl is-active secrets-mcp && echo '服务启动成功' || (sudo journalctl -u secrets-mcp -n 20 && exit 1) " - rm -f /tmp/deploy_key - name: 飞书通知 if: always() diff --git a/.vscode/tasks.json b/.vscode/tasks.json index d437cf8..2148020 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -22,7 +22,6 @@ "label": "test: workspace", "type": "shell", "command": "cargo test --workspace --locked", - "dependsOn": "build", "group": { "kind": "test", "isDefault": true } }, { @@ -35,7 +34,7 @@ "label": "clippy: workspace", "type": "shell", "command": "cargo clippy --workspace --locked -- -D warnings", - "dependsOn": "build" + "problemMatcher": [] }, { "label": "ci: release-check", diff --git a/Cargo.lock b/Cargo.lock index 5c321ba..faf27f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2066,7 +2066,7 @@ dependencies = [ [[package]] name = "secrets-mcp" -version = "0.5.5" +version = "0.5.6" dependencies = [ "anyhow", "askama", diff --git a/README.md b/README.md index e34dccf..f362765 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,13 @@ cargo build --release -p secrets-mcp | `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`。容器内或直接对外暴露端口时请改为 `0.0.0.0:9315`;反代时常为 `127.0.0.1:9315`。 | | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;不配置则无 Google 登录入口。运行时从环境读取,勿写入 CI、勿打入二进制。 | | `RUST_LOG` | 可选;日志级别,如 `secrets_mcp=debug`。 | +| `SECRETS_DATABASE_POOL_SIZE` | 可选。连接池最大连接数,默认 `10`。 | +| `SECRETS_DATABASE_ACQUIRE_TIMEOUT` | 可选。获取连接超时秒数,默认 `5`。 | +| `RATE_LIMIT_GLOBAL_PER_SECOND` | 可选。全局限流速率,默认 `100` req/s。 | +| `RATE_LIMIT_GLOBAL_BURST` | 可选。全局限流突发量,默认 `200`。 | +| `RATE_LIMIT_IP_PER_SECOND` | 可选。单 IP 限流速率,默认 `20` req/s。 | +| `RATE_LIMIT_IP_BURST` | 可选。单 IP 限流突发量,默认 `40`。 | +| `TRUST_PROXY` | 可选。设为 `1`/`true`/`yes` 时从 `X-Forwarded-For` / `X-Real-IP` 提取客户端 IP;仅在反代环境下启用。 | ```bash cargo run -p secrets-mcp diff --git a/crates/secrets-core/src/service/import.rs b/crates/secrets-core/src/service/import.rs index 82a5634..bc4b624 100644 --- a/crates/secrets-core/src/service/import.rs +++ b/crates/secrets-core/src/service/import.rs @@ -54,7 +54,13 @@ pub async fn run( .bind(params.user_id) .fetch_one(pool) .await - .unwrap_or(false); + .map_err(|e| { + anyhow::anyhow!( + "Failed to check entry existence for '{}': {}", + entry.name, + e + ) + })?; if exists && !params.force { return Err(anyhow::anyhow!( diff --git a/crates/secrets-core/src/service/rollback.rs b/crates/secrets-core/src/service/rollback.rs index 425cfcb..1767724 100644 --- a/crates/secrets-core/src/service/rollback.rs +++ b/crates/secrets-core/src/service/rollback.rs @@ -228,9 +228,11 @@ pub async fn run( } sqlx::query( - "UPDATE entries SET tags = $1, metadata = $2, version = version + 1, \ - updated_at = NOW() WHERE id = $3", + "UPDATE entries SET folder = $1, type = $2, tags = $3, metadata = $4, version = version + 1, \ + updated_at = NOW() WHERE id = $5", ) + .bind(&snap.folder) + .bind(&snap.entry_type) .bind(&snap.tags) .bind(&snap.metadata) .bind(lr.id) diff --git a/crates/secrets-core/src/service/user.rs b/crates/secrets-core/src/service/user.rs index b932a50..7d5350c 100644 --- a/crates/secrets-core/src/service/user.rs +++ b/crates/secrets-core/src/service/user.rs @@ -200,10 +200,14 @@ pub async fn unbind_oauth_account( ); } - let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM oauth_accounts WHERE user_id = $1") - .bind(user_id) - .fetch_one(pool) - .await?; + let mut tx = pool.begin().await?; + + let locked_accounts: Vec<(String,)> = + sqlx::query_as("SELECT provider FROM oauth_accounts WHERE user_id = $1 FOR UPDATE") + .bind(user_id) + .fetch_all(&mut *tx) + .await?; + let count = locked_accounts.len(); if count <= 1 { anyhow::bail!("Cannot unbind the last OAuth account. Please link another account first."); @@ -212,8 +216,87 @@ pub async fn unbind_oauth_account( sqlx::query("DELETE FROM oauth_accounts WHERE user_id = $1 AND provider = $2") .bind(user_id) .bind(provider) - .execute(pool) + .execute(&mut *tx) .await?; + tx.commit().await?; + Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + async fn maybe_test_pool() -> Option { + let database_url = match std::env::var("SECRETS_DATABASE_URL") { + Ok(v) => v, + Err(_) => { + eprintln!("skip user service tests: SECRETS_DATABASE_URL not set"); + return None; + } + }; + let pool = match sqlx::PgPool::connect(&database_url).await { + Ok(pool) => pool, + Err(e) => { + eprintln!("skip user service tests: cannot connect to database: {e}"); + return None; + } + }; + if let Err(e) = crate::db::migrate(&pool).await { + eprintln!("skip user service tests: migrate failed: {e}"); + return None; + } + Some(pool) + } + + async fn cleanup_user_rows(pool: &PgPool, user_id: Uuid) -> Result<()> { + sqlx::query("DELETE FROM oauth_accounts WHERE user_id = $1") + .bind(user_id) + .execute(pool) + .await?; + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user_id) + .execute(pool) + .await?; + Ok(()) + } + + #[tokio::test] + async fn unbind_oauth_account_removes_only_requested_provider() -> Result<()> { + let Some(pool) = maybe_test_pool().await else { + return Ok(()); + }; + let user_id = Uuid::from_u128(rand::random()); + + cleanup_user_rows(&pool, user_id).await?; + + sqlx::query("INSERT INTO users (id, name) VALUES ($1, '')") + .bind(user_id) + .execute(&pool) + .await?; + sqlx::query( + "INSERT INTO oauth_accounts (user_id, provider, provider_id, email, name, avatar_url) \ + VALUES ($1, 'google', $2, NULL, NULL, NULL), \ + ($1, 'github', $3, NULL, NULL, NULL)", + ) + .bind(user_id) + .bind(format!("google-{user_id}")) + .bind(format!("github-{user_id}")) + .execute(&pool) + .await?; + + unbind_oauth_account(&pool, user_id, "github", Some("google")).await?; + + let remaining: Vec<(String,)> = sqlx::query_as( + "SELECT provider FROM oauth_accounts WHERE user_id = $1 ORDER BY provider", + ) + .bind(user_id) + .fetch_all(&pool) + .await?; + assert_eq!(remaining, vec![("google".to_string(),)]); + + cleanup_user_rows(&pool, user_id).await?; + Ok(()) + } +} diff --git a/crates/secrets-mcp/Cargo.toml b/crates/secrets-mcp/Cargo.toml index fd2d85f..249bd86 100644 --- a/crates/secrets-mcp/Cargo.toml +++ b/crates/secrets-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets-mcp" -version = "0.5.5" +version = "0.5.6" edition.workspace = true [[bin]] diff --git a/crates/secrets-mcp/src/logging.rs b/crates/secrets-mcp/src/logging.rs index c50dc34..b20466b 100644 --- a/crates/secrets-mcp/src/logging.rs +++ b/crates/secrets-mcp/src/logging.rs @@ -1,9 +1,8 @@ -use std::net::SocketAddr; use std::time::Instant; use axum::{ body::{Body, Bytes, to_bytes}, - extract::{ConnectInfo, Request}, + extract::Request, http::{ HeaderMap, Method, StatusCode, header::{CONTENT_LENGTH, CONTENT_TYPE, USER_AGENT}, @@ -245,18 +244,5 @@ fn header_str(headers: &HeaderMap, name: impl axum::http::header::AsHeaderName) } fn client_ip(req: &Request) -> Option { - if let Some(first) = req - .headers() - .get("x-forwarded-for") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.split(',').next()) - { - let s = first.trim(); - if !s.is_empty() { - return Some(s.to_string()); - } - } - req.extensions() - .get::>() - .map(|c| c.ip().to_string()) + crate::client_ip::extract_client_ip(req).into() } diff --git a/crates/secrets-mcp/src/main.rs b/crates/secrets-mcp/src/main.rs index f6188ac..7ecfbd3 100644 --- a/crates/secrets-mcp/src/main.rs +++ b/crates/secrets-mcp/src/main.rs @@ -187,6 +187,9 @@ async fn main() -> Result<()> { )) .layer(session_layer) .layer(cors) + .layer(tower_http::limit::RequestBodyLimitLayer::new( + 10 * 1024 * 1024, + )) .with_state(app_state); // ── Start server ────────────────────────────────────────────────────────── diff --git a/crates/secrets-mcp/src/tools.rs b/crates/secrets-mcp/src/tools.rs index 848533a..016a72b 100644 --- a/crates/secrets-mcp/src/tools.rs +++ b/crates/secrets-mcp/src/tools.rs @@ -230,15 +230,6 @@ impl SecretsService { } } - /// Extract user_id from the HTTP request parts injected by auth middleware. - fn user_id_from_ctx(ctx: &RequestContext) -> Result, rmcp::ErrorData> { - let parts = ctx - .extensions - .get::() - .ok_or_else(mcp_err_missing_http_parts)?; - Ok(parts.extensions.get::().map(|a| a.user_id)) - } - /// Get the authenticated user_id (returns error if not authenticated). fn require_user_id(ctx: &RequestContext) -> Result { let parts = ctx @@ -1142,7 +1133,7 @@ impl SecretsService { ctx: RequestContext, ) -> Result { let t = Instant::now(); - let user_id = Self::user_id_from_ctx(&ctx)?; + let user_id = Self::require_user_id(&ctx)?; // Safety: require at least one filter. if input.id.is_none() @@ -1172,9 +1163,9 @@ impl SecretsService { if let Some(ref id_str) = input.id { let eid = parse_uuid(id_str)?; let uid = user_id; - let entry = resolve_entry_by_id(&self.pool, eid, uid) + let entry = resolve_entry_by_id(&self.pool, eid, Some(uid)) .await - .map_err(|e| mcp_err_internal_logged("secrets_delete", uid, e))?; + .map_err(|e| mcp_err_internal_logged("secrets_delete", Some(uid), e))?; (Some(entry.name), Some(entry.folder)) } else { (input.name.clone(), input.folder.clone()) @@ -1187,11 +1178,11 @@ impl SecretsService { folder: effective_folder.as_deref(), entry_type: input.entry_type.as_deref(), dry_run: input.dry_run.unwrap_or(false), - user_id, + user_id: Some(user_id), }, ) .await - .map_err(|e| mcp_err_internal_logged("secrets_delete", user_id, e))?; + .map_err(|e| mcp_err_internal_logged("secrets_delete", Some(user_id), e))?; tracing::info!( tool = "secrets_delete", @@ -1218,7 +1209,7 @@ impl SecretsService { ctx: RequestContext, ) -> Result { let t = Instant::now(); - let user_id = Self::user_id_from_ctx(&ctx)?; + let user_id = Self::require_user_id(&ctx)?; tracing::info!( tool = "secrets_history", ?user_id, @@ -1230,9 +1221,9 @@ impl SecretsService { let (resolved_name, resolved_folder): (String, Option) = if let Some(ref id_str) = input.id { let eid = parse_uuid(id_str)?; - let entry = resolve_entry_by_id(&self.pool, eid, user_id) + let entry = resolve_entry_by_id(&self.pool, eid, Some(user_id)) .await - .map_err(|e| mcp_err_internal_logged("secrets_history", user_id, e))?; + .map_err(|e| mcp_err_internal_logged("secrets_history", Some(user_id), e))?; (entry.name, Some(entry.folder)) } else { (input.name.clone(), input.folder.clone()) @@ -1243,10 +1234,10 @@ impl SecretsService { &resolved_name, resolved_folder.as_deref(), input.limit.unwrap_or(20), - user_id, + Some(user_id), ) .await - .map_err(|e| mcp_err_internal_logged("secrets_history", user_id, e))?; + .map_err(|e| mcp_err_internal_logged("secrets_history", Some(user_id), e))?; tracing::info!( tool = "secrets_history", diff --git a/crates/secrets-mcp/src/web.rs b/crates/secrets-mcp/src/web.rs index 6a710ba..bcddb25 100644 --- a/crates/secrets-mcp/src/web.rs +++ b/crates/secrets-mcp/src/web.rs @@ -1,6 +1,6 @@ use askama::Template; use chrono::SecondsFormat; -use std::net::SocketAddr; +use std::net::{IpAddr, SocketAddr}; use axum::{ Json, Router, @@ -176,14 +176,33 @@ async fn current_user_id(session: &Session) -> Option { } fn request_client_ip(headers: &HeaderMap, connect_info: ConnectInfo) -> Option { - if let Some(first) = headers - .get("x-forwarded-for") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.split(',').next()) - { - let value = first.trim(); - if !value.is_empty() { - return Some(value.to_string()); + let trust_proxy = std::env::var("TRUST_PROXY") + .as_deref() + .is_ok_and(|v| matches!(v, "1" | "true" | "yes")); + request_client_ip_with_trust_proxy(headers, connect_info, trust_proxy) +} + +fn request_client_ip_with_trust_proxy( + headers: &HeaderMap, + connect_info: ConnectInfo, + trust_proxy: bool, +) -> Option { + if trust_proxy { + if let Some(first) = headers + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.split(',').next()) + { + let value = first.trim(); + if let Ok(ip) = value.parse::() { + return Some(ip.to_string()); + } + } + if let Some(value) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) { + let value = value.trim(); + if let Ok(ip) = value.parse::() { + return Some(ip.to_string()); + } } } @@ -234,10 +253,6 @@ pub fn web_router() -> Router { .route("/entries", get(entries_page)) .route("/audit", get(audit_page)) .route("/account/bind/google", get(account_bind_google)) - .route( - "/account/bind/google/callback", - get(account_bind_google_callback), - ) .route("/account/unbind/{provider}", post(account_unbind)) .route("/api/key-salt", get(api_key_salt)) .route("/api/key-setup", post(api_key_setup)) @@ -909,14 +924,9 @@ async fn account_bind_google( StatusCode::INTERNAL_SERVER_ERROR })?; - let redirect_uri = format!("{}/account/bind/google/callback", state.base_url); - let mut cfg = state - .google_config - .clone() - .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; - cfg.redirect_uri = redirect_uri; - let st = random_state(); - if let Err(e) = session.insert(SESSION_OAUTH_STATE, &st).await { + let config = google_cfg(&state).ok_or(StatusCode::SERVICE_UNAVAILABLE)?; + let oauth_state = random_state(); + if let Err(e) = session.insert(SESSION_OAUTH_STATE, &oauth_state).await { tracing::error!(error = %e, "failed to insert oauth_state for account bind flow"); if let Err(rm) = session.remove::(SESSION_OAUTH_BIND_MODE).await { tracing::warn!(error = %rm, "failed to roll back oauth_bind_mode after oauth_state insert failure"); @@ -924,34 +934,8 @@ async fn account_bind_google( return Err(StatusCode::INTERNAL_SERVER_ERROR); } - Ok(Redirect::to(&google_auth_url(&cfg, &st)).into_response()) -} - -async fn account_bind_google_callback( - State(state): State, - connect_info: ConnectInfo, - headers: HeaderMap, - session: Session, - Query(params): Query, -) -> Result { - let client_ip = request_client_ip(&headers, connect_info); - let user_agent = request_user_agent(&headers); - handle_oauth_callback( - &state, - &session, - params, - "google", - client_ip.as_deref(), - user_agent.as_deref(), - |s, cfg, code| { - Box::pin(crate::oauth::google::exchange_code( - &s.http_client, - cfg, - code, - )) - }, - ) - .await + let url = google_auth_url(config, &oauth_state); + Ok(Redirect::to(&url).into_response()) } async fn account_unbind( @@ -1742,6 +1726,34 @@ fn format_audit_target(folder: &str, entry_type: &str, name: &str) -> String { mod tests { use super::*; + #[test] + fn request_client_ip_ignores_forwarded_headers_without_trusted_proxy() { + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-for", "203.0.113.10".parse().unwrap()); + + let ip = request_client_ip_with_trust_proxy( + &headers, + ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 9315))), + false, + ); + + assert_eq!(ip.as_deref(), Some("127.0.0.1")); + } + + #[test] + fn request_client_ip_uses_valid_forwarded_header_with_trusted_proxy() { + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-for", "203.0.113.10, 10.0.0.1".parse().unwrap()); + + let ip = request_client_ip_with_trust_proxy( + &headers, + ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 9315))), + true, + ); + + assert_eq!(ip.as_deref(), Some("203.0.113.10")); + } + #[test] fn request_ui_lang_prefers_zh_cn_over_en_fallback() { let mut headers = HeaderMap::new(); diff --git a/deploy/.env.example b/deploy/.env.example index efb3961..6ade06b 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -30,3 +30,24 @@ GOOGLE_CLIENT_SECRET= # ─── 日志(可选)────────────────────────────────────────────────────── # RUST_LOG=secrets_mcp=debug + +# ─── 数据库连接池(可选)────────────────────────────────────────────── +# 最大连接数,默认 10 +# SECRETS_DATABASE_POOL_SIZE=10 +# 获取连接超时秒数,默认 5 +# SECRETS_DATABASE_ACQUIRE_TIMEOUT=5 + +# ─── 限流(可选)────────────────────────────────────────────────────── +# 全局限流速率(req/s),默认 100 +# RATE_LIMIT_GLOBAL_PER_SECOND=100 +# 全局限流突发量,默认 200 +# RATE_LIMIT_GLOBAL_BURST=200 +# 单 IP 限流速率(req/s),默认 20 +# RATE_LIMIT_IP_PER_SECOND=20 +# 单 IP 限流突发量,默认 40 +# RATE_LIMIT_IP_BURST=40 + +# ─── 代理信任(可选)───────────────────────────────────────────────── +# 设为 1/true/yes 时从 X-Forwarded-For / X-Real-IP 提取客户端 IP +# 仅在反代环境下启用,否则客户端可伪造 IP 绕过限流 +# TRUST_PROXY=1 diff --git a/scripts/sync-test-to-prod.sh b/scripts/sync-test-to-prod.sh deleted file mode 100755 index 3fe9c42..0000000 --- a/scripts/sync-test-to-prod.sh +++ /dev/null @@ -1,95 +0,0 @@ -#!/bin/bash -# 同步测试环境数据到生产环境 -# 用法: ./scripts/sync-test-to-prod.sh - -set -euo pipefail - -# PostgreSQL 客户端工具路径 (Homebrew libpq) -export PATH="/opt/homebrew/opt/libpq/bin:$PATH" - -# SSL 配置 -export PGSSLMODE=verify-full -export PGSSLROOTCERT=/etc/ssl/cert.pem - -# 测试环境 -TEST_DB="postgres://postgres:Voson_2026_Pg18!@db.refining.ltd:5432/secrets-nn-test" - -# 生产环境 -PROD_DB="postgres://postgres:Voson_2026_Pg18!@db.refining.ltd:5432/secrets-nn-prod" - -echo "=========================================" -echo " 测试环境 -> 生产环境 数据同步" -echo "=========================================" -echo "" - -# 确认操作 -read -p "⚠️ 此操作将覆盖生产环境数据,确认继续? (yes/no): " confirm -if [ "$confirm" != "yes" ]; then - echo "已取消" - exit 0 -fi - -echo "" -echo "步骤 1/4: 导出测试环境数据..." -TEMP_DIR=$(mktemp -d) -trap "rm -rf $TEMP_DIR" EXIT - -# 导出测试环境数据(不含审计日志和历史记录) -pg_dump "$TEST_DB" \ - --table=entries \ - --table=secrets \ - --table=entry_secrets \ - --table=users \ - --table=oauth_accounts \ - --data-only \ - --column-inserts \ - --no-owner \ - --no-privileges \ - > "$TEMP_DIR/test_data.sql" - -echo "✓ 测试数据已导出到临时文件" -echo " 文件大小: $(du -h "$TEMP_DIR/test_data.sql" | cut -f1)" - -echo "" -echo "步骤 2/4: 备份当前生产数据..." -pg_dump "$PROD_DB" \ - --table=entries \ - --table=secrets \ - --table=entry_secrets \ - --table=users \ - --table=oauth_accounts \ - --data-only \ - --column-inserts \ - --no-owner \ - --no-privileges \ - > "$TEMP_DIR/prod_backup_$(date +%Y%m%d_%H%M%S).sql" - -echo "✓ 生产数据已备份" - -echo "" -echo "步骤 3/4: 清空生产环境目标表..." -psql "$PROD_DB" <<'SQL' -TRUNCATE TABLE entry_secrets CASCADE; -TRUNCATE TABLE secrets CASCADE; -TRUNCATE TABLE entries CASCADE; -SQL - -echo "✓ 生产环境目标表已清空" - -echo "" -echo "步骤 4/4: 导入测试数据到生产环境..." -psql "$PROD_DB" -f "$TEMP_DIR/test_data.sql" 2>&1 | tail -20 - -echo "" -echo "验证数据..." -echo "生产环境数据统计:" -psql "$PROD_DB" -c "SELECT 'users' as table_name, count(*) FROM users UNION ALL SELECT 'entries', count(*) FROM entries UNION ALL SELECT 'secrets', count(*) FROM secrets UNION ALL SELECT 'entry_secrets', count(*) FROM entry_secrets UNION ALL SELECT 'oauth_accounts', count(*) FROM oauth_accounts ORDER BY table_name;" - -echo "" -echo "=========================================" -echo " ✓ 数据同步完成!" -echo "=========================================" -echo "" -echo "提示:" -echo " - 生产数据备份已保存在临时目录" -echo " - 临时文件将在脚本退出后自动删除"