This commit is contained in:
duanshuwen committed 2026-08-25 19:18:58 +08:00
commit 8f000811a8
15 files changed
+794 -1

No files matched your search

-1
View File
@@ -15,7 +15,6 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY alembic.ini .
COPY alembic ./alembic
COPY app ./app
COPY data ./data
EXPOSE 4000
@@ -40,6 +40,20 @@ def _drop_column_with_foreign_keys(table_name: str, column_name: str) -> None:
op.drop_column(table_name, column_name)
def _drop_referencing_foreign_keys(table_name: str) -> None:
bind = op.get_bind()
inspector = inspect(bind)
for current_table in inspector.get_table_names():
if current_table == table_name:
continue
for foreign_key in inspector.get_foreign_keys(current_table):
if foreign_key.get("referred_table") != table_name:
continue
constraint_name = foreign_key.get("name")
if constraint_name:
op.drop_constraint(constraint_name, current_table, type_="foreignkey")
def upgrade() -> None:
bind = op.get_bind()
existing_tables = set(inspect(bind).get_table_names())
@@ -49,6 +63,7 @@ def upgrade() -> None:
for table_name in (*PRODUCT_TABLES[:3], "RouteSection", "DemandRecommendation", *PRODUCT_TABLES[3:]):
if table_name in existing_tables:
_drop_referencing_foreign_keys(table_name)
op.drop_table(table_name)
@@ -53,6 +53,8 @@ SEED_TEAM_BUILDINGS = (
"detailParagraphs": ["洞穴、瀑降与协作,适合 10-30 人。"],
"image": "https://dimg04.c-ctrip.com/images/1mh0412000njfr1ot9453_W_640_10000.jpg?proc=autoorient",
"demandKeyword": "户外团建",
"detailSubtitle": "洞穴、瀑降与协作,适合 10-30 人。",
"detailParagraphs": ["洞穴、瀑降与协作,适合 10-30 人。"],
"isActive": True,
"sortOrder": 0,
},
@@ -65,6 +67,8 @@ SEED_TEAM_BUILDINGS = (
"detailParagraphs": ["溯溪与分组协作,兼顾参与感与安全。"],
"image": "https://dimg04.c-ctrip.com/images/0EQ5712000ca7t504EC0E_W_640_10000.jpg?proc=autoorient",
"demandKeyword": "峡谷团建",
"detailSubtitle": "溯溪与分组协作,兼顾参与感与安全。",
"detailParagraphs": ["溯溪与分组协作,兼顾参与感与安全。"],
"isActive": True,
"sortOrder": 1,
},
@@ -77,6 +81,8 @@ SEED_TEAM_BUILDINGS = (
"detailParagraphs": ["夜游、长桌宴与文化体验,适合团建收尾。"],
"image": "https://p6.itc.cn/q_70/images03/20200918/df728d2b79d943da869333e2ea2c92c8.jpeg",
"demandKeyword": "贵州团建",
"detailSubtitle": "夜游、长桌宴与文化体验,适合团建收尾。",
"detailParagraphs": ["夜游、长桌宴与文化体验,适合团建收尾。"],
"isActive": True,
"sortOrder": 2,
},
@@ -106,6 +112,7 @@ SEED_WILD_ARCHIVES = (
"title": "石龙洞",
"image": "https://www.zurnal24.si/media/img/5e/d5/9526a56dba168aa136f3.jpeg",
"images": ["https://www.zurnal24.si/media/img/5e/d5/9526a56dba168aa136f3.jpeg"],
"images": ["https://www.zurnal24.si/media/img/5e/d5/9526a56dba168aa136f3.jpeg"],
"demandKeyword": "地心探险",
"isActive": True,
"sortOrder": 1,
@@ -115,6 +122,7 @@ SEED_WILD_ARCHIVES = (
"title": "绝壁迎流",
"image": "https://q9.itc.cn/q_70/images03/20250810/b62f5afc191a4947a66e6b32721b0235.jpeg",
"images": ["https://q9.itc.cn/q_70/images03/20250810/b62f5afc191a4947a66e6b32721b0235.jpeg"],
"images": ["https://q9.itc.cn/q_70/images03/20250810/b62f5afc191a4947a66e6b32721b0235.jpeg"],
"demandKeyword": "峡谷探险",
"isActive": True,
"sortOrder": 2,
@@ -7,6 +7,7 @@ Revises: 0018_home_wanfa_recommendations
from alembic import context, op
import sqlalchemy as sa
from sqlalchemy import inspect
from sqlalchemy import inspect
from sqlalchemy.dialects import postgresql
@@ -7,6 +7,7 @@ Revises: 0019_home_wild_archive_images
from alembic import context, op
import sqlalchemy as sa
from sqlalchemy import inspect
from sqlalchemy import inspect
from sqlalchemy.dialects import postgresql
@@ -9,6 +9,7 @@ from datetime import datetime
from alembic import context, op
import sqlalchemy as sa
from sqlalchemy import inspect
from sqlalchemy import inspect
from sqlalchemy.dialects import postgresql
@@ -93,6 +94,12 @@ def upgrade() -> None:
for index_name in missing_detail_record_indexes(existing_index_names):
op.create_index(index_name, "DetailRecord", DETAIL_RECORD_INDEXES[index_name])
def upgrade() -> None:
bind = op.get_bind()
if "DetailRecord" not in set(inspect(bind).get_table_names()):
_create_detail_table()
detail_table = sa.table(
"DetailRecord",
sa.column("id", sa.String()),
@@ -7,6 +7,7 @@ Revises: 0022_opaque_ids
from alembic import context, op
import sqlalchemy as sa
from sqlalchemy import inspect
from sqlalchemy import inspect
revision = "0023_detail_concierge_advisor"
@@ -3,6 +3,7 @@
from alembic import context, op
import sqlalchemy as sa
from sqlalchemy import inspect
from sqlalchemy import inspect
from sqlalchemy.dialects import postgresql
@@ -0,0 +1,229 @@
from datetime import datetime
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
import pytest
import sqlalchemy as sa
VERSIONS_DIR = Path(__file__).parents[1] / "alembic" / "versions"
def load_migration(filename: str):
path = VERSIONS_DIR / filename
spec = spec_from_file_location(f"bootstrap_compatibility_{path.stem}", path)
assert spec and spec.loader
migration = module_from_spec(spec)
spec.loader.exec_module(migration)
return migration
class CurrentSchemaInspector:
tables = {
"Customer",
"DetailRecord",
"HomeTeamBuilding",
"HomeWildArchive",
"Lead",
"VehicleServiceConfig",
}
columns = {
"DetailRecord": {"conciergeAdvisorId"},
"HomeTeamBuilding": {"detailParagraphs", "detailSubtitle"},
"HomeWildArchive": {"images"},
"Lead": {"contactName", "customerId", "leadType", "vehicleDemand"},
"VehicleServiceConfig": {
"advantages",
"intro",
"introTitle",
"isActive",
"processSteps",
"serviceSections",
},
}
def get_table_names(self) -> list[str]:
return sorted(self.tables)
def get_columns(self, table_name: str) -> list[dict[str, object]]:
return [
{"name": name, "type": sa.String()}
for name in sorted(self.columns.get(table_name, set()))
]
def get_indexes(self, table_name: str) -> list[dict[str, object]]:
indexes = {
"Lead": {"ix_Lead_customerId", "ix_Lead_leadType"},
"VehicleServiceConfig": {"ix_VehicleServiceConfig_isActive"},
}
return [{"name": name} for name in sorted(indexes.get(table_name, set()))]
def get_foreign_keys(self, table_name: str) -> list[dict[str, object]]:
if table_name != "Lead":
return []
return [
{
"name": "Lead_customerId_fkey",
"constrained_columns": ["customerId"],
"referred_table": "Customer",
"referred_columns": ["id"],
}
]
class ExistingRowsResult:
def first(self):
return ("existing",)
def mappings(self):
return self
def all(self) -> list[object]:
return []
class ExistingRowsBind:
def execute(self, _statement) -> ExistingRowsResult:
return ExistingRowsResult()
def record_operation(monkeypatch, migration, operation: str, events: list[tuple[str, ...]]) -> None:
monkeypatch.setattr(
migration.op,
operation,
lambda *args, **_kwargs: events.append((operation, *(str(arg) for arg in args))),
)
def test_home_content_seed_rows_cover_current_non_null_detail_columns():
migration = load_migration("0017_home_content.py")
timestamp = datetime(2026, 1, 1)
team_rows = migration._prepare_seed_rows(
migration.SEED_TEAM_BUILDINGS,
{"id", "description", "detailSubtitle", "detailParagraphs", "createdAt", "updatedAt"},
timestamp,
)
archive_rows = migration._prepare_seed_rows(
migration.SEED_WILD_ARCHIVES,
{"id", "image", "images", "createdAt", "updatedAt"},
timestamp,
)
assert team_rows[0]["detailSubtitle"] == team_rows[0]["description"]
assert team_rows[0]["detailParagraphs"] == [team_rows[0]["description"]]
assert archive_rows[0]["images"] == [archive_rows[0]["image"]]
def test_home_content_online_seed_reflects_current_table_columns(monkeypatch):
migration = load_migration("0017_home_content.py")
inspector = CurrentSchemaInspector()
inspector.tables = {"HomeTeamBuilding"}
inspector.columns = {
"HomeTeamBuilding": {
"createdAt",
"demandKeyword",
"description",
"detailParagraphs",
"detailSubtitle",
"id",
"image",
"isActive",
"sortOrder",
"tag",
"title",
"updatedAt",
}
}
inserted: dict[str, object] = {}
monkeypatch.setattr(migration.context, "is_offline_mode", lambda: False)
monkeypatch.setattr(migration, "inspect", lambda _bind: inspector, raising=False)
monkeypatch.setattr(migration.op, "get_bind", lambda: object())
monkeypatch.setattr(
migration.op,
"bulk_insert",
lambda table, rows: inserted.update(table=table, rows=rows),
)
historical_columns = [
sa.Column("id", sa.String()),
sa.Column("description", sa.Text()),
]
migration._seed_table(
"HomeTeamBuilding",
historical_columns,
migration.SEED_TEAM_BUILDINGS,
)
inserted_table = inserted["table"]
inserted_rows = inserted["rows"]
assert set(inserted_table.c.keys()) == inspector.columns["HomeTeamBuilding"]
assert inserted_rows[0]["detailSubtitle"] == inserted_rows[0]["description"]
assert inserted_rows[0]["detailParagraphs"] == [inserted_rows[0]["description"]]
@pytest.mark.parametrize(
("filename", "table_name", "column_names"),
[
("0019_home_wild_archive_images.py", "HomeWildArchive", {"images"}),
(
"0020_home_team_building_details.py",
"HomeTeamBuilding",
{"detailParagraphs", "detailSubtitle"},
),
("0023_detail_concierge_advisor.py", "DetailRecord", {"conciergeAdvisorId"}),
],
)
def test_column_migrations_do_not_add_columns_already_in_current_schema(
monkeypatch,
filename: str,
table_name: str,
column_names: set[str],
):
migration = load_migration(filename)
inspector = CurrentSchemaInspector()
inspector.tables = {table_name}
inspector.columns = {table_name: column_names}
added_columns: list[tuple[str, ...]] = []
monkeypatch.setattr(migration, "inspect", lambda _bind: inspector, raising=False)
monkeypatch.setattr(migration.op, "get_bind", lambda: object())
record_operation(monkeypatch, migration, "add_column", added_columns)
monkeypatch.setattr(migration.op, "execute", lambda *_args, **_kwargs: None)
monkeypatch.setattr(migration.op, "alter_column", lambda *_args, **_kwargs: None)
migration.upgrade()
assert added_columns == []
def test_detail_record_migration_reuses_populated_current_table(monkeypatch):
migration = load_migration("0021_detail_records.py")
inspector = CurrentSchemaInspector()
events: list[tuple[str, ...]] = []
monkeypatch.setattr(migration, "inspect", lambda _bind: inspector, raising=False)
monkeypatch.setattr(migration.op, "get_bind", lambda: ExistingRowsBind())
for operation in ("bulk_insert", "create_index", "create_table"):
record_operation(monkeypatch, migration, operation, events)
migration.upgrade()
assert events == []
def test_vehicle_demand_migration_reuses_current_schema_objects(monkeypatch):
migration = load_migration("0024_vehicle_demand.py")
inspector = CurrentSchemaInspector()
events: list[tuple[str, ...]] = []
monkeypatch.setattr(migration, "inspect", lambda _bind: inspector, raising=False)
monkeypatch.setattr(migration.op, "get_bind", lambda: object())
for operation in ("add_column", "create_foreign_key", "create_index", "create_table"):
record_operation(monkeypatch, migration, operation, events)
monkeypatch.setattr(migration.op, "alter_column", lambda *_args, **_kwargs: None)
migration.upgrade()
assert events == []
@@ -0,0 +1,67 @@
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
MIGRATION_PATH = Path(__file__).parents[1] / "alembic" / "versions" / "0009_remove_product_domain.py"
SPEC = spec_from_file_location("remove_product_domain_migration", MIGRATION_PATH)
assert SPEC and SPEC.loader
MIGRATION = module_from_spec(SPEC)
SPEC.loader.exec_module(MIGRATION)
class LegacySchemaInspector:
def get_table_names(self) -> list[str]:
return ["Product", "MiniProgramFavorite", "MiniProgramHistory"]
def get_foreign_keys(self, table_name: str) -> list[dict[str, object]]:
foreign_keys = {
"MiniProgramFavorite": [
{
"name": "MiniProgramFavorite_productId_fkey",
"constrained_columns": ["productId"],
"referred_table": "Product",
"referred_columns": ["id"],
}
],
"MiniProgramHistory": [
{
"name": "MiniProgramHistory_productId_fkey",
"constrained_columns": ["productId"],
"referred_table": "Product",
"referred_columns": ["id"],
}
],
}
return foreign_keys.get(table_name, [])
def test_upgrade_drops_legacy_product_foreign_keys_before_product_table(monkeypatch):
events: list[tuple[str, ...]] = []
inspector = LegacySchemaInspector()
monkeypatch.setattr(MIGRATION, "inspect", lambda _bind: inspector)
monkeypatch.setattr(MIGRATION.op, "get_bind", lambda: object())
monkeypatch.setattr(
MIGRATION.op,
"drop_constraint",
lambda name, table_name, type_: events.append(("drop_constraint", name, table_name, type_)),
)
monkeypatch.setattr(MIGRATION.op, "drop_table", lambda table_name: events.append(("drop_table", table_name)))
MIGRATION.upgrade()
assert events == [
(
"drop_constraint",
"MiniProgramFavorite_productId_fkey",
"MiniProgramFavorite",
"foreignkey",
),
(
"drop_constraint",
"MiniProgramHistory_productId_fkey",
"MiniProgramHistory",
"foreignkey",
),
("drop_table", "Product"),
]