From b76e5bd515e7fb94737884180f3a103f1f3fb403 Mon Sep 17 00:00:00 2001 From: chenshuobo <1119780489@qq.com> Date: Fri, 29 May 2026 15:20:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20SQLite=20=E2=86=92=20PostgreSQL=20?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E4=BB=A3=E7=A0=81=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 docs/数据库迁移.md 第 3 节扫雷清单落地代码层改动: - pyproject.toml: 新增 psycopg[binary]>=3.1 依赖 (psycopg3, SQLAlchemy 2.0 时代默认, 不要再装 psycopg2) - app/db/session.py: 非 SQLite 自动加连接池参数 - pool_size=10, max_overflow=20, pool_recycle=3600 - SQLite 单文件不池化, _is_sqlite 判断分支保留以便本地 dev 临时回退 - app/models/savings.py: dishes 列从 JSON 改为 JSONB - PG 上能用 GIN 索引和 jsonb 操作符 - SQLite 上 JSON 实际是 TEXT, 切到 PG 后用 JSON 类型存的是 json 不是 jsonb - alembic/versions/ef96beb47b1e_*.py: 新增迁移把 savings_record.dishes 从 json 转 jsonb - 用 op.get_bind().dialect.name 判断 PG 才执行 ALTER COLUMN - SQLite 上是 no-op (SQLite 没有 jsonb 类型) - 旧的 savings_shop_dishes.py 迁移保持 sa.JSON() 不动 (按文档"不改老迁移"原则) 本地切 PG 验证: 10 张表全部建出, dishes 字段为 jsonb 类型, 登录/me/美团 feed 三个真接口全 200, user 数据正确写入 PG. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...onvert_savings_record_dishes_from_json_.py | 36 +++++++++++++++++++ app/db/session.py | 22 +++++++----- app/models/savings.py | 7 ++-- pyproject.toml | 3 ++ 4 files changed, 57 insertions(+), 11 deletions(-) create mode 100644 alembic/versions/ef96beb47b1e_convert_savings_record_dishes_from_json_.py 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",