diff --git a/docs/数据库迁移.md b/docs/数据库迁移.md index 9dcd593..3661a0b 100644 --- a/docs/数据库迁移.md +++ b/docs/数据库迁移.md @@ -15,20 +15,37 @@ alembic upgrade head --- ## 1. 首次初始化(clone 后) + +### 1.1 用 PostgreSQL(推荐,与生产一致) +前置:本机已装 PostgreSQL 16 + 知道 postgres 超级用户密码。 + ```bash -# (1) 装依赖(在你的虚拟环境里) +# (1) 装依赖(在你的虚拟环境里, 含 psycopg) pip install -e . -# (2) 配 .env(从模板复制,至少填 JWT_SECRET_KEY) -cp .env.example .env +# (2) 一键建用户 + 建库 + 写 .env + 跑迁移 +python scripts/init_postgres.py +# 按提示输入 PG 超级用户密码即可。脚本会: +# - 自动建业务用户 shaguabijia_app + 业务库 shaguabijia +# - 自动生成业务用户强密码,写入 .env 的 DATABASE_URL +# - 跑完 alembic upgrade head -# (3) SQLite 默认库需要 data/ 目录存在 +# (3) 启动 +./run.sh +``` + +可用环境变量提前指定(免交互): +```bash +PG_SUPER_PASS=xxx APP_DB_PASS=yyy python scripts/init_postgres.py +``` +脚本幂等,重复跑会重置业务用户密码、跳过已存在的库。 + +### 1.2 用 SQLite(本地开发临时用,不接生产链路时) +```bash +pip install -e . +cp .env.example .env # 不动 DATABASE_URL, 默认 sqlite:///./data/app.db mkdir -p data - -# (4) 跑迁移建表 —— 关键 alembic upgrade head - -# (5) 启动(run.sh 会自动重跑 3+4,所以平时直接 ./run.sh 也行) ./run.sh ``` > 也可以直接 `bash scripts/migrate.sh` 只做迁移、不启服务(部署/CI 用)。 diff --git a/scripts/init_postgres.py b/scripts/init_postgres.py new file mode 100644 index 0000000..0890dbc --- /dev/null +++ b/scripts/init_postgres.py @@ -0,0 +1,189 @@ +"""Bootstrap PostgreSQL: 建用户 + 建库 + 授权 + 写 .env + 跑迁移。 + +新机器初始化用。前置:已装 PostgreSQL 16 + 知道 postgres 超级用户密码。 + +用法: + python scripts/init_postgres.py + +环境变量(可选,不填会交互式问): + PG_HOST PG 主机, 默认 localhost + PG_PORT PG 端口, 默认 5432 + PG_SUPER_USER 超级用户, 默认 postgres + PG_SUPER_PASS 超级用户密码 (不填会 getpass 交互输入) + APP_DB_NAME 业务库名, 默认 shaguabijia + APP_DB_USER 业务用户名, 默认 shaguabijia_app + APP_DB_PASS 业务用户密码 (不填会自动生成强密码) + +幂等:已存在的用户/库不会重复建,已配的 .env 会被覆盖前提示。 +""" +from __future__ import annotations + +import getpass +import os +import secrets +import subprocess +import sys +from pathlib import Path + +try: + import psycopg + from psycopg import sql +except ImportError: + print("❌ 缺 psycopg。先跑: pip install -e .") + sys.exit(1) + + +ROOT = Path(__file__).resolve().parent.parent +ENV_FILE = ROOT / ".env" +ENV_EXAMPLE = ROOT / ".env.example" + + +def env_or_input(key: str, prompt: str, default: str = "", secret: bool = False) -> str: + val = os.environ.get(key, "").strip() + if val: + return val + if secret: + return getpass.getpass(f"{prompt}: ").strip() + suffix = f" [{default}]" if default else "" + raw = input(f"{prompt}{suffix}: ").strip() + return raw or default + + +def ensure_role(conn: psycopg.Connection, user: str, password: str) -> None: + # 注意: CREATE/ALTER USER 不支持参数化密码, 必须用 sql.Literal 内联 + with conn.cursor() as cur: + cur.execute("SELECT 1 FROM pg_roles WHERE rolname=%s", (user,)) + if cur.fetchone(): + print(f" · 用户 {user} 已存在, 重置密码") + cur.execute( + sql.SQL("ALTER USER {} WITH PASSWORD {}").format( + sql.Identifier(user), sql.Literal(password) + ) + ) + else: + print(f" · 创建用户 {user}") + cur.execute( + sql.SQL("CREATE USER {} WITH PASSWORD {}").format( + sql.Identifier(user), sql.Literal(password) + ) + ) + + +def ensure_database(conn: psycopg.Connection, db: str, owner: str) -> None: + with conn.cursor() as cur: + cur.execute("SELECT 1 FROM pg_database WHERE datname=%s", (db,)) + if cur.fetchone(): + print(f" · 数据库 {db} 已存在") + return + print(f" · 创建数据库 {db} (owner={owner}, encoding=UTF8)") + cur.execute( + sql.SQL("CREATE DATABASE {} OWNER {} ENCODING 'UTF8'").format( + sql.Identifier(db), sql.Identifier(owner) + ) + ) + + +def grant_all(conn: psycopg.Connection, db: str, user: str) -> None: + with conn.cursor() as cur: + cur.execute( + sql.SQL("GRANT ALL PRIVILEGES ON DATABASE {} TO {}").format( + sql.Identifier(db), sql.Identifier(user) + ) + ) + + +def write_env(database_url: str) -> None: + if not ENV_FILE.exists(): + if ENV_EXAMPLE.exists(): + ENV_FILE.write_text(ENV_EXAMPLE.read_text(encoding="utf-8"), encoding="utf-8") + print(f" · 从 .env.example 复制出 .env") + else: + ENV_FILE.write_text("", encoding="utf-8") + + lines = ENV_FILE.read_text(encoding="utf-8").splitlines() + new_lines = [] + replaced = False + for line in lines: + if line.startswith("DATABASE_URL="): + old_url = line.split("=", 1)[1] + if old_url.strip() and old_url.strip() != database_url: + ans = input(f"\n.env 里已有 DATABASE_URL=\n {old_url}\n覆盖吗? [y/N]: ").strip().lower() + if ans != "y": + print(" · 保留原 DATABASE_URL") + new_lines.append(line) + replaced = True + continue + new_lines.append(f"DATABASE_URL={database_url}") + replaced = True + else: + new_lines.append(line) + if not replaced: + new_lines.append(f"DATABASE_URL={database_url}") + + ENV_FILE.write_text("\n".join(new_lines) + "\n", encoding="utf-8") + print(f" · 写入 .env -> DATABASE_URL") + + +def run_alembic_upgrade() -> bool: + print("\n[3/3] 跑 alembic upgrade head") + try: + subprocess.run( + [sys.executable, "-m", "alembic", "upgrade", "head"], + cwd=ROOT, + check=True, + ) + return True + except subprocess.CalledProcessError as e: + print(f"❌ alembic 失败: {e}") + return False + + +def main() -> int: + print("=" * 60) + print("PostgreSQL 初始化脚本 — shaguabijia-app-server") + print("=" * 60) + + host = env_or_input("PG_HOST", "PG host", "localhost") + port = env_or_input("PG_PORT", "PG port", "5432") + super_user = env_or_input("PG_SUPER_USER", "PG superuser", "postgres") + super_pass = env_or_input("PG_SUPER_PASS", "PG superuser password", secret=True) + db_name = env_or_input("APP_DB_NAME", "App database name", "shaguabijia") + db_user = env_or_input("APP_DB_USER", "App database user", "shaguabijia_app") + db_pass = os.environ.get("APP_DB_PASS", "").strip() + if not db_pass: + db_pass = secrets.token_urlsafe(32) + print(f" · 自动生成业务用户密码: {db_pass}") + + print(f"\n[1/3] 连接 PG (host={host}:{port}, user={super_user})") + try: + conn = psycopg.connect( + host=host, port=int(port), user=super_user, password=super_pass, + dbname="postgres", autocommit=True, + ) + except psycopg.OperationalError as e: + print(f"❌ 连不上 PG: {e}") + print(" 检查 1) PG 服务是否在跑 2) 超级用户密码是否正确 3) 端口防火墙是否放行") + return 1 + + print("\n[2/3] 建用户 + 建库 + 授权") + with conn: + ensure_role(conn, db_user, db_pass) + ensure_database(conn, db_name, db_user) + grant_all(conn, db_name, db_user) + + database_url = f"postgresql+psycopg://{db_user}:{db_pass}@{host}:{port}/{db_name}" + write_env(database_url) + + if not run_alembic_upgrade(): + return 1 + + print("\n" + "=" * 60) + print("✅ 全部完成") + print(f" DATABASE_URL = {database_url}") + print(" 下一步: 启动服务 -> ./run.sh 或 uvicorn app.main:app --reload --port 8770") + print("=" * 60) + return 0 + + +if __name__ == "__main__": + sys.exit(main())