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/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",