diff --git a/alembic/versions/ef96beb47b1e_convert_savings_record_dishes_from_json_.py b/alembic/versions/ef96beb47b1e_convert_savings_record_dishes_from_json_.py new file mode 100644 index 0000000..ce7436b --- /dev/null +++ b/alembic/versions/ef96beb47b1e_convert_savings_record_dishes_from_json_.py @@ -0,0 +1,36 @@ +"""convert savings_record.dishes from json to jsonb + +PG only. SQLite 上 JSON/JSONB 都是 TEXT, 此迁移是 no-op。 + +Revision ID: ef96beb47b1e +Revises: c8d9e0f1a2b3 +Create Date: 2026-05-29 10:57:21.471774 +""" +from typing import Sequence, Union + +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB + + +revision: str = 'ef96beb47b1e' +down_revision: Union[str, Sequence[str], None] = 'c8d9e0f1a2b3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + if op.get_bind().dialect.name != "postgresql": + return + op.execute( + "ALTER TABLE savings_record " + "ALTER COLUMN dishes TYPE JSONB USING dishes::JSONB" + ) + + +def downgrade() -> None: + if op.get_bind().dialect.name != "postgresql": + return + op.execute( + "ALTER TABLE savings_record " + "ALTER COLUMN dishes TYPE JSON USING dishes::JSON" + ) diff --git a/app/db/session.py b/app/db/session.py index 1014900..97fc7e8 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -27,19 +27,25 @@ def _ensure_sqlite_dir(url: str) -> None: _ensure_sqlite_dir(settings.DATABASE_URL) +_is_sqlite = settings.DATABASE_URL.startswith("sqlite") + # SQLite 跨线程访问要 check_same_thread=False;PG/MySQL 不需要这个参数 _connect_args: dict = {} -if settings.DATABASE_URL.startswith("sqlite"): +if _is_sqlite: _connect_args["check_same_thread"] = False -engine = create_engine( - settings.DATABASE_URL, - connect_args=_connect_args, +_engine_kwargs: dict = { + "connect_args": _connect_args, # echo 在 dev 下打 SQL,生产关掉 - echo=settings.APP_DEBUG and not settings.is_prod, - future=True, - pool_pre_ping=True, -) + "echo": settings.APP_DEBUG and not settings.is_prod, + "future": True, + "pool_pre_ping": True, +} +# SQLite 用单文件不需要池;PG/MySQL 必须显式池化 + recycle 防 idle 断连 +if not _is_sqlite: + _engine_kwargs.update(pool_size=10, max_overflow=20, pool_recycle=3600) + +engine = create_engine(settings.DATABASE_URL, **_engine_kwargs) SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False) diff --git a/app/models/savings.py b/app/models/savings.py index 29fabf5..317a670 100644 --- a/app/models/savings.py +++ b/app/models/savings.py @@ -8,7 +8,8 @@ from __future__ import annotations from datetime import datetime -from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, func +from sqlalchemy import DateTime, ForeignKey, Integer, String, func +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -28,8 +29,8 @@ class SavingsRecord(Base): title: Mapped[str | None] = mapped_column(String(128), nullable=True) # 店铺名(订单明细卡标题,如「窑鸡王(王府井店)」) shop_name: Mapped[str | None] = mapped_column(String(128), nullable=True) - # 菜品名列表(JSON),前 2 道直接展示,其余收进「还有 N 道菜」展开。SQLite 存为 TEXT。 - dishes: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) + # 菜品名列表(JSONB),PG 上可建 GIN 索引,前 2 道直接展示,其余收进「还有 N 道菜」展开。 + dishes: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list) # 来源:demo(演示) / compare(真实比价上报) source: Mapped[str] = mapped_column(String(16), nullable=False, default="compare") 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/pyproject.toml b/pyproject.toml index f5e812e..bc4a55e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,9 @@ dependencies = [ "sqlalchemy>=2.0.35", "alembic>=1.13.3", + # PostgreSQL 驱动 (psycopg3, SQLAlchemy 2.0 时代默认, 不要再装 psycopg2) + "psycopg[binary]>=3.1", + # JWT 签名 / 校验 "pyjwt[crypto]>=2.9.0", 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())