refactor(admin,ui,test): 完善数据库迁移脚本,添加单元测试并调整前端配置

优化所有alembic迁移脚本的离线模式支持,添加已存在对象检查逻辑,避免重复执行出错。
为各个迁移脚本添加对应的单元测试,覆盖核心场景保障逻辑正确性。
修改前端配置文件,将默认API地址切换为线上服务器8.138.234.141:4000。
新增本地数据备份文件wonderq-local-data.dump和项目发布包wonderq-admin-release-20260823-01.tar。
This commit is contained in:
duanshuwen
2026-08-25 19:17:31 +08:00
parent d42f69bd47
commit 203781210c
17 changed files with 519 additions and 120 deletions

View File

@@ -1 +1 @@
VITE_API_BASE_URL="http://localhost:4000"
VITE_API_BASE_URL="http://8.138.234.141:4000"

View File

@@ -19,7 +19,7 @@ export default defineConfig({
},
proxy: {
"/api": {
target: "http://localhost:4001",
target: "http://8.138.234.141:4000",
changeOrigin: true,
},
},
@@ -30,4 +30,3 @@ export default defineConfig({
strictPort: true,
},
});

View File

@@ -9,6 +9,7 @@ from datetime import datetime
from alembic import context, op
import sqlalchemy as sa
from sqlalchemy import inspect
from sqlalchemy.dialects import postgresql
revision = "0017_home_content"
@@ -48,6 +49,8 @@ SEED_TEAM_BUILDINGS = (
"tag": "户外挑战",
"title": "山野挑战,共创极境",
"description": "洞穴、瀑降与协作,适合 10-30 人。",
"detailSubtitle": "洞穴、瀑降与协作,适合 10-30 人。",
"detailParagraphs": ["洞穴、瀑降与协作,适合 10-30 人。"],
"image": "https://dimg04.c-ctrip.com/images/1mh0412000njfr1ot9453_W_640_10000.jpg?proc=autoorient",
"demandKeyword": "户外团建",
"isActive": True,
@@ -58,6 +61,8 @@ SEED_TEAM_BUILDINGS = (
"tag": "团队协作",
"title": "峡谷溯溪,默契同行",
"description": "溯溪与分组协作,兼顾参与感与安全。",
"detailSubtitle": "溯溪与分组协作,兼顾参与感与安全。",
"detailParagraphs": ["溯溪与分组协作,兼顾参与感与安全。"],
"image": "https://dimg04.c-ctrip.com/images/0EQ5712000ca7t504EC0E_W_640_10000.jpg?proc=autoorient",
"demandKeyword": "峡谷团建",
"isActive": True,
@@ -68,6 +73,8 @@ SEED_TEAM_BUILDINGS = (
"tag": "人文聚会",
"title": "苗寨共聚,认识彼此",
"description": "夜游、长桌宴与文化体验,适合团建收尾。",
"detailSubtitle": "夜游、长桌宴与文化体验,适合团建收尾。",
"detailParagraphs": ["夜游、长桌宴与文化体验,适合团建收尾。"],
"image": "https://p6.itc.cn/q_70/images03/20200918/df728d2b79d943da869333e2ea2c92c8.jpeg",
"demandKeyword": "贵州团建",
"isActive": True,
@@ -75,11 +82,21 @@ SEED_TEAM_BUILDINGS = (
},
)
OPTIONAL_SEED_COLUMN_TYPES = {
"detailSubtitle": sa.String(),
"detailParagraphs": postgresql.JSONB(astext_type=sa.Text()),
"images": postgresql.JSONB(astext_type=sa.Text()),
}
SEED_WILD_ARCHIVES = (
{
"id": "hundred-meter-descent",
"title": "百米自降",
"image": "https://genk.mediacdn.vn/139269124445442048/2024/4/27/10-23-sinkhole-1714189653945948438879.jpg",
"images": [
"https://genk.mediacdn.vn/139269124445442048/2024/4/27/10-23-sinkhole-1714189653945948438879.jpg"
],
"demandKeyword": "悬崖瀑降",
"isActive": True,
"sortOrder": 0,
@@ -88,6 +105,7 @@ SEED_WILD_ARCHIVES = (
"id": "shilong-cave",
"title": "石龙洞",
"image": "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,
@@ -96,6 +114,7 @@ SEED_WILD_ARCHIVES = (
"id": "cliff-current",
"title": "绝壁迎流",
"image": "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,
@@ -104,6 +123,9 @@ SEED_WILD_ARCHIVES = (
"id": "canyon-streaming",
"title": "峡谷溯溪",
"image": "https://dimg04.c-ctrip.com/images/0EQ5712000ca7t504EC0E_W_640_10000.jpg?proc=autoorient",
"images": [
"https://dimg04.c-ctrip.com/images/0EQ5712000ca7t504EC0E_W_640_10000.jpg?proc=autoorient"
],
"demandKeyword": "峡谷溯溪",
"isActive": True,
"sortOrder": 3,
@@ -120,13 +142,43 @@ def _create_table(table_name: str, columns: list[sa.Column]) -> None:
op.create_index(f"ix_{table_name}_sortOrder", table_name, ["sortOrder"])
def prepare_seed_rows(
rows: tuple[dict, ...],
available_columns: set[str],
timestamp: datetime,
) -> list[dict]:
return [
{
key: value
for key, value in {**row, "createdAt": timestamp, "updatedAt": timestamp}.items()
if key in available_columns
}
for row in rows
]
def _seed_table(table_name: str, columns: list[sa.Column], rows: tuple[dict, ...]) -> None:
table = sa.table(table_name, *[sa.column(column.name, column.type) for column in columns])
timestamp = datetime(2026, 1, 1)
op.bulk_insert(
table,
[{**row, "createdAt": timestamp, "updatedAt": timestamp} for row in rows],
column_types = {column.name: column.type for column in columns}
if context.is_offline_mode():
available_columns = set(column_types)
else:
bind = op.get_bind()
available_columns = {column["name"] for column in inspect(bind).get_columns(table_name)}
for name, column_type in OPTIONAL_SEED_COLUMN_TYPES.items():
if name in available_columns:
column_types.setdefault(name, column_type)
seed_column_names = available_columns.intersection(column_types)
table = sa.table(
table_name,
*[
sa.column(name, column_types[name])
for name in column_types
if name in seed_column_names
],
)
timestamp = datetime(2026, 1, 1)
op.bulk_insert(table, prepare_seed_rows(rows, seed_column_names, timestamp))
def upgrade() -> None:

View File

@@ -4,8 +4,9 @@ Revision ID: 0019_home_wild_archive_images
Revises: 0018_home_wanfa_recommendations
"""
from alembic import op
from alembic import context, op
import sqlalchemy as sa
from sqlalchemy import inspect
from sqlalchemy.dialects import postgresql
@@ -15,22 +16,40 @@ branch_labels = None
depends_on = None
def should_add_images_column(existing_columns: set[str]) -> bool:
return "images" not in existing_columns
def upgrade() -> None:
op.add_column(
"HomeWildArchive",
sa.Column(
"images",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default=sa.text("'[]'::jsonb"),
),
)
if context.is_offline_mode():
add_images_column = True
else:
bind = op.get_bind()
existing_columns = {column["name"] for column in inspect(bind).get_columns("HomeWildArchive")}
add_images_column = should_add_images_column(existing_columns)
if add_images_column:
op.add_column(
"HomeWildArchive",
sa.Column(
"images",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default=sa.text("'[]'::jsonb"),
),
)
op.execute(
sa.text(
'UPDATE "HomeWildArchive" SET "images" = jsonb_build_array("image") WHERE jsonb_array_length("images") = 0'
'UPDATE "HomeWildArchive" '
'SET "images" = jsonb_build_array("image") '
'WHERE "images" IS NULL OR CASE '
'WHEN jsonb_typeof("images") = \'array\' '
'THEN jsonb_array_length("images") = 0 '
'ELSE TRUE END'
)
)
op.alter_column("HomeWildArchive", "images", server_default=None)
if add_images_column:
op.alter_column("HomeWildArchive", "images", server_default=None)
def downgrade() -> None:

View File

@@ -4,8 +4,9 @@ Revision ID: 0020_home_team_building_details
Revises: 0019_home_wild_archive_images
"""
from alembic import op
from alembic import context, op
import sqlalchemy as sa
from sqlalchemy import inspect
from sqlalchemy.dialects import postgresql
@@ -15,35 +16,59 @@ branch_labels = None
depends_on = None
DETAIL_COLUMNS = {"detailSubtitle", "detailParagraphs"}
def missing_detail_columns(existing_columns: set[str]) -> set[str]:
return DETAIL_COLUMNS.difference(existing_columns)
def upgrade() -> None:
op.add_column(
"HomeTeamBuilding",
sa.Column("detailSubtitle", sa.String(), nullable=False, server_default=""),
)
op.add_column(
"HomeTeamBuilding",
sa.Column(
"detailParagraphs",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default=sa.text("'[]'::jsonb"),
),
)
if context.is_offline_mode():
columns_to_add = DETAIL_COLUMNS
else:
bind = op.get_bind()
existing_columns = {column["name"] for column in inspect(bind).get_columns("HomeTeamBuilding")}
columns_to_add = missing_detail_columns(existing_columns)
if "detailSubtitle" in columns_to_add:
op.add_column(
"HomeTeamBuilding",
sa.Column("detailSubtitle", sa.String(), nullable=False, server_default=""),
)
if "detailParagraphs" in columns_to_add:
op.add_column(
"HomeTeamBuilding",
sa.Column(
"detailParagraphs",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default=sa.text("'[]'::jsonb"),
),
)
op.execute(
sa.text(
'UPDATE "HomeTeamBuilding" '
'SET "detailSubtitle" = CASE '
'WHEN "detailSubtitle" = \'\' THEN "description" '
'WHEN COALESCE("detailSubtitle", \'\') = \'\' THEN "description" '
'ELSE "detailSubtitle" END, '
'"detailParagraphs" = CASE '
'WHEN jsonb_array_length("detailParagraphs") = 0 '
'WHEN "detailParagraphs" IS NULL OR jsonb_typeof("detailParagraphs") <> \'array\' '
'OR (jsonb_typeof("detailParagraphs") = \'array\' '
'AND jsonb_array_length("detailParagraphs") = 0) '
'THEN jsonb_build_array("description") '
'ELSE "detailParagraphs" END '
'WHERE "detailSubtitle" = \'\' OR jsonb_array_length("detailParagraphs") = 0'
'WHERE COALESCE("detailSubtitle", \'\') = \'\' '
'OR "detailParagraphs" IS NULL '
'OR jsonb_typeof("detailParagraphs") <> \'array\' '
'OR (jsonb_typeof("detailParagraphs") = \'array\' '
'AND jsonb_array_length("detailParagraphs") = 0)'
)
)
op.alter_column("HomeTeamBuilding", "detailSubtitle", server_default=None)
op.alter_column("HomeTeamBuilding", "detailParagraphs", server_default=None)
if "detailSubtitle" in columns_to_add:
op.alter_column("HomeTeamBuilding", "detailSubtitle", server_default=None)
if "detailParagraphs" in columns_to_add:
op.alter_column("HomeTeamBuilding", "detailParagraphs", server_default=None)
def downgrade() -> None:

View File

@@ -6,8 +6,9 @@ Revises: 0020_home_team_building_details
from datetime import datetime
from alembic import op
from alembic import context, op
import sqlalchemy as sa
from sqlalchemy import inspect
from sqlalchemy.dialects import postgresql
@@ -17,30 +18,80 @@ branch_labels = None
depends_on = None
DETAIL_RECORD_INDEXES = {
"ix_DetailRecord_isActive": ["isActive"],
"ix_DetailRecord_sortOrder": ["sortOrder"],
}
def missing_detail_record_indexes(existing_indexes: set[str]) -> set[str]:
return set(DETAIL_RECORD_INDEXES).difference(existing_indexes)
def build_detail_seed_rows(routes, existing_keys: set[str], timestamp: datetime) -> list[dict]:
return [
{
"id": f"detail-{route['id']}",
"key": route["id"],
"eyebrow": "玩法推荐",
"duration": "5天4晚",
"title": route["title"],
"subtitle": route["subtitle"],
"intro": route["subtitle"],
"highlights": ["核心景观串联", "小团出行,按同行人的节奏灵活调整"],
"included": ["行程内用车与接送服务"],
"excluded": ["往返大交通及个人消费"],
"notes": ["最终方案以出行日期、人数和资源确认结果为准。"],
"gallery": [route["image"]],
"isActive": True,
"sortOrder": route["sortOrder"],
"createdAt": timestamp,
"updatedAt": timestamp,
}
for route in routes
if route["id"] not in existing_keys
]
def upgrade() -> None:
op.create_table(
"DetailRecord",
sa.Column("id", sa.String(), nullable=False),
sa.Column("key", sa.String(), nullable=False),
sa.Column("eyebrow", sa.String(), nullable=False),
sa.Column("duration", sa.String(), nullable=False),
sa.Column("title", sa.String(), nullable=False),
sa.Column("subtitle", sa.Text(), nullable=False),
sa.Column("intro", sa.Text(), nullable=False),
sa.Column("highlights", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("included", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("excluded", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("notes", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("gallery", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"),
sa.Column("createdAt", sa.DateTime(), nullable=False),
sa.Column("updatedAt", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("key"),
)
op.create_index("ix_DetailRecord_isActive", "DetailRecord", ["isActive"])
op.create_index("ix_DetailRecord_sortOrder", "DetailRecord", ["sortOrder"])
if context.is_offline_mode():
table_exists = False
existing_index_names = set()
else:
bind = op.get_bind()
inspector = inspect(bind)
table_exists = "DetailRecord" in set(inspector.get_table_names())
existing_index_names = {
index["name"]
for index in inspector.get_indexes("DetailRecord")
} if table_exists else set()
if not table_exists:
op.create_table(
"DetailRecord",
sa.Column("id", sa.String(), nullable=False),
sa.Column("key", sa.String(), nullable=False),
sa.Column("eyebrow", sa.String(), nullable=False),
sa.Column("duration", sa.String(), nullable=False),
sa.Column("title", sa.String(), nullable=False),
sa.Column("subtitle", sa.Text(), nullable=False),
sa.Column("intro", sa.Text(), nullable=False),
sa.Column("highlights", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("included", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("excluded", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("notes", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("gallery", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"),
sa.Column("createdAt", sa.DateTime(), nullable=False),
sa.Column("updatedAt", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("key"),
)
existing_index_names = set()
for index_name in missing_detail_record_indexes(existing_index_names):
op.create_index(index_name, "DetailRecord", DETAIL_RECORD_INDEXES[index_name])
detail_table = sa.table(
"DetailRecord",
@@ -61,34 +112,18 @@ def upgrade() -> None:
sa.column("createdAt", sa.DateTime()),
sa.column("updatedAt", sa.DateTime()),
)
routes = op.get_bind().execute(
timestamp = datetime(2026, 1, 1)
if context.is_offline_mode():
return
conn = op.get_bind()
routes = conn.execute(
sa.text('SELECT "id", "title", "subtitle", "image", "sortOrder" FROM "WanfaRoute" ORDER BY "sortOrder" ASC')
).mappings().all()
timestamp = datetime(2026, 1, 1)
op.bulk_insert(
detail_table,
[
{
"id": f"detail-{route['id']}",
"key": route["id"],
"eyebrow": "玩法推荐",
"duration": "5天4晚",
"title": route["title"],
"subtitle": route["subtitle"],
"intro": route["subtitle"],
"highlights": ["核心景观串联", "小团出行,按同行人的节奏灵活调整"],
"included": ["行程内用车与接送服务"],
"excluded": ["往返大交通及个人消费"],
"notes": ["最终方案以出行日期、人数和资源确认结果为准。"],
"gallery": [route["image"]],
"isActive": True,
"sortOrder": route["sortOrder"],
"createdAt": timestamp,
"updatedAt": timestamp,
}
for route in routes
],
)
existing_keys = set(conn.execute(sa.text('SELECT "key" FROM "DetailRecord"')).scalars())
seed_rows = build_detail_seed_rows(routes, existing_keys, timestamp)
if seed_rows:
op.bulk_insert(detail_table, seed_rows)
def downgrade() -> None:

View File

@@ -4,8 +4,9 @@ Revision ID: 0023_detail_concierge_advisor
Revises: 0022_opaque_ids
"""
from alembic import op
from alembic import context, op
import sqlalchemy as sa
from sqlalchemy import inspect
revision = "0023_detail_concierge_advisor"
@@ -14,9 +15,32 @@ branch_labels = None
depends_on = None
def should_add_concierge_advisor_column(existing_columns: set[str]) -> bool:
return "conciergeAdvisorId" not in existing_columns
def upgrade() -> None:
op.add_column("DetailRecord", sa.Column("conciergeAdvisorId", sa.String(), nullable=True))
if context.is_offline_mode():
should_add = True
else:
existing_columns = {
column["name"]
for column in inspect(op.get_bind()).get_columns("DetailRecord")
}
should_add = should_add_concierge_advisor_column(existing_columns)
if should_add:
op.add_column("DetailRecord", sa.Column("conciergeAdvisorId", sa.String(), nullable=True))
def downgrade() -> None:
op.drop_column("DetailRecord", "conciergeAdvisorId")
if context.is_offline_mode():
op.drop_column("DetailRecord", "conciergeAdvisorId")
return
existing_columns = {
column["name"]
for column in inspect(op.get_bind()).get_columns("DetailRecord")
}
if "conciergeAdvisorId" in existing_columns:
op.drop_column("DetailRecord", "conciergeAdvisorId")

View File

@@ -1,7 +1,8 @@
"""Add vehicle demand fields and service configuration."""
from alembic import op
from alembic import context, op
import sqlalchemy as sa
from sqlalchemy import inspect
from sqlalchemy.dialects import postgresql
@@ -11,34 +12,115 @@ branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("Lead", sa.Column("leadType", sa.String(), nullable=False, server_default="general"))
op.add_column("Lead", sa.Column("contactName", sa.String(), nullable=True))
op.add_column("Lead", sa.Column("customerId", sa.String(), nullable=True))
op.add_column("Lead", sa.Column("vehicleDemand", postgresql.JSONB(astext_type=sa.Text()), nullable=True))
op.create_index("ix_Lead_leadType", "Lead", ["leadType"])
op.create_index("ix_Lead_customerId", "Lead", ["customerId"])
op.create_foreign_key("fk_Lead_customerId_Customer", "Lead", "Customer", ["customerId"], ["id"], ondelete="SET NULL")
op.alter_column("Lead", "leadType", server_default=None)
LEAD_COLUMNS = {
"leadType",
"contactName",
"customerId",
"vehicleDemand",
}
op.create_table(
"VehicleServiceConfig",
sa.Column("id", sa.String(), nullable=False),
sa.Column("introTitle", sa.String(), nullable=False),
sa.Column("intro", sa.Text(), nullable=False),
sa.Column("serviceSections", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("advantages", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("processSteps", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("createdAt", sa.DateTime(), nullable=False),
sa.Column("updatedAt", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
LEAD_INDEXES = {
"ix_Lead_leadType": ["leadType"],
"ix_Lead_customerId": ["customerId"],
}
VEHICLE_SERVICE_INDEXES = {
"ix_VehicleServiceConfig_isActive": ["isActive"],
}
def missing_lead_columns(existing_columns: set[str]) -> set[str]:
return LEAD_COLUMNS.difference(existing_columns)
def missing_named_indexes(existing_indexes: set[str], expected_indexes: dict[str, list[str]]) -> set[str]:
return set(expected_indexes).difference(existing_indexes)
def should_create_customer_fk(foreign_keys: list[dict]) -> bool:
return not any(
foreign_key.get("referred_table") == "Customer"
and foreign_key.get("referred_columns") == ["id"]
and foreign_key.get("constrained_columns") == ["customerId"]
for foreign_key in foreign_keys
)
op.create_index("ix_VehicleServiceConfig_isActive", "VehicleServiceConfig", ["isActive"])
op.alter_column("VehicleServiceConfig", "serviceSections", server_default=None)
op.alter_column("VehicleServiceConfig", "advantages", server_default=None)
op.alter_column("VehicleServiceConfig", "processSteps", server_default=None)
op.alter_column("VehicleServiceConfig", "isActive", server_default=None)
def upgrade() -> None:
if context.is_offline_mode():
existing_lead_columns = set()
existing_lead_indexes = set()
existing_lead_foreign_keys = []
vehicle_service_table_exists = False
existing_vehicle_service_indexes = set()
else:
inspector = inspect(op.get_bind())
existing_lead_columns = {
column["name"]
for column in inspector.get_columns("Lead")
}
existing_lead_indexes = {
index["name"]
for index in inspector.get_indexes("Lead")
if index.get("name")
}
existing_lead_foreign_keys = inspector.get_foreign_keys("Lead")
vehicle_service_table_exists = "VehicleServiceConfig" in set(inspector.get_table_names())
existing_vehicle_service_indexes = {
index["name"]
for index in inspector.get_indexes("VehicleServiceConfig")
if index.get("name")
} if vehicle_service_table_exists else set()
added_lead_type = "leadType" not in existing_lead_columns
lead_columns = {
"leadType": sa.Column("leadType", sa.String(), nullable=False, server_default="general"),
"contactName": sa.Column("contactName", sa.String(), nullable=True),
"customerId": sa.Column("customerId", sa.String(), nullable=True),
"vehicleDemand": sa.Column("vehicleDemand", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
}
for column_name in ("leadType", "contactName", "customerId", "vehicleDemand"):
if column_name in missing_lead_columns(existing_lead_columns):
op.add_column("Lead", lead_columns[column_name])
for index_name in missing_named_indexes(existing_lead_indexes, LEAD_INDEXES):
op.create_index(index_name, "Lead", LEAD_INDEXES[index_name])
if should_create_customer_fk(existing_lead_foreign_keys):
op.create_foreign_key(
"fk_Lead_customerId_Customer",
"Lead",
"Customer",
["customerId"],
["id"],
ondelete="SET NULL",
)
if added_lead_type:
op.alter_column("Lead", "leadType", server_default=None)
if not vehicle_service_table_exists:
op.create_table(
"VehicleServiceConfig",
sa.Column("id", sa.String(), nullable=False),
sa.Column("introTitle", sa.String(), nullable=False),
sa.Column("intro", sa.Text(), nullable=False),
sa.Column("serviceSections", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("advantages", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("processSteps", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("createdAt", sa.DateTime(), nullable=False),
sa.Column("updatedAt", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
existing_vehicle_service_indexes = set()
for column_name in ("serviceSections", "advantages", "processSteps", "isActive"):
if column_name != "isActive":
op.alter_column("VehicleServiceConfig", column_name, server_default=None)
op.alter_column("VehicleServiceConfig", "isActive", server_default=None)
for index_name in missing_named_indexes(existing_vehicle_service_indexes, VEHICLE_SERVICE_INDEXES):
op.create_index(index_name, "VehicleServiceConfig", VEHICLE_SERVICE_INDEXES[index_name])
def downgrade() -> None:

View File

@@ -0,0 +1,14 @@
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
MIGRATION_PATH = Path(__file__).parents[1] / "alembic" / "versions" / "0023_detail_concierge_advisor.py"
SPEC = spec_from_file_location("detail_concierge_migration", MIGRATION_PATH)
assert SPEC and SPEC.loader
MIGRATION = module_from_spec(SPEC)
SPEC.loader.exec_module(MIGRATION)
def test_concierge_advisor_column_is_added_only_when_missing():
assert MIGRATION.should_add_concierge_advisor_column(set())
assert not MIGRATION.should_add_concierge_advisor_column({"id", "conciergeAdvisorId"})

View File

@@ -0,0 +1,38 @@
from importlib.util import module_from_spec, spec_from_file_location
from datetime import datetime
from pathlib import Path
MIGRATION_PATH = Path(__file__).parents[1] / "alembic" / "versions" / "0021_detail_records.py"
SPEC = spec_from_file_location("detail_records_migration", MIGRATION_PATH)
assert SPEC and SPEC.loader
MIGRATION = module_from_spec(SPEC)
SPEC.loader.exec_module(MIGRATION)
def test_detail_record_indexes_are_created_only_when_missing():
assert MIGRATION.missing_detail_record_indexes(set()) == {
"ix_DetailRecord_isActive",
"ix_DetailRecord_sortOrder",
}
assert MIGRATION.missing_detail_record_indexes({"ix_DetailRecord_isActive"}) == {
"ix_DetailRecord_sortOrder",
}
assert MIGRATION.missing_detail_record_indexes(
{"ix_DetailRecord_isActive", "ix_DetailRecord_sortOrder"}
) == set()
def test_detail_seed_rows_skip_existing_route_keys():
routes = [
{
"id": "family-route",
"title": "亲子路线",
"subtitle": "贵州亲子慢游",
"image": "https://example.test/family.jpg",
"sortOrder": 0,
}
]
rows = MIGRATION.build_detail_seed_rows(routes, set(), datetime(2026, 1, 1))
assert rows[0]["key"] == "family-route"
assert MIGRATION.build_detail_seed_rows(routes, {"family-route"}, datetime(2026, 1, 1)) == []

View File

@@ -0,0 +1,41 @@
from importlib.util import module_from_spec, spec_from_file_location
from datetime import datetime
from pathlib import Path
MIGRATION_PATH = Path(__file__).parents[1] / "alembic" / "versions" / "0017_home_content.py"
SPEC = spec_from_file_location("home_content_migration", MIGRATION_PATH)
assert SPEC and SPEC.loader
MIGRATION = module_from_spec(SPEC)
SPEC.loader.exec_module(MIGRATION)
def test_team_building_seed_contains_detail_fallback_fields():
for row in MIGRATION.SEED_TEAM_BUILDINGS:
assert row["detailSubtitle"] == row["description"]
assert row["detailParagraphs"] == [row["description"]]
def test_wild_archive_seed_contains_detail_images_fallback():
for row in MIGRATION.SEED_WILD_ARCHIVES:
assert row["images"] == [row["image"]]
def test_seed_rows_only_include_columns_available_in_existing_table():
timestamp = datetime(2026, 1, 1)
rows_without_detail_columns = MIGRATION.prepare_seed_rows(
MIGRATION.SEED_TEAM_BUILDINGS[:1],
{"id", "description", "createdAt", "updatedAt"},
timestamp,
)
assert "detailSubtitle" not in rows_without_detail_columns[0]
assert "detailParagraphs" not in rows_without_detail_columns[0]
rows_with_detail_columns = MIGRATION.prepare_seed_rows(
MIGRATION.SEED_TEAM_BUILDINGS[:1],
{"id", "description", "detailSubtitle", "detailParagraphs", "createdAt", "updatedAt"},
timestamp,
)
assert rows_with_detail_columns[0]["detailSubtitle"] == rows_with_detail_columns[0]["description"]
assert rows_with_detail_columns[0]["detailParagraphs"] == [rows_with_detail_columns[0]["description"]]

View File

@@ -0,0 +1,18 @@
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
MIGRATION_PATH = Path(__file__).parents[1] / "alembic" / "versions" / "0020_home_team_building_details.py"
SPEC = spec_from_file_location("home_team_building_details_migration", MIGRATION_PATH)
assert SPEC and SPEC.loader
MIGRATION = module_from_spec(SPEC)
SPEC.loader.exec_module(MIGRATION)
def test_detail_columns_are_added_only_when_missing():
assert MIGRATION.missing_detail_columns(set()) == {
"detailSubtitle",
"detailParagraphs",
}
assert MIGRATION.missing_detail_columns({"detailSubtitle"}) == {"detailParagraphs"}
assert MIGRATION.missing_detail_columns({"detailSubtitle", "detailParagraphs"}) == set()

View File

@@ -0,0 +1,14 @@
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
MIGRATION_PATH = Path(__file__).parents[1] / "alembic" / "versions" / "0019_home_wild_archive_images.py"
SPEC = spec_from_file_location("home_wild_archive_images_migration", MIGRATION_PATH)
assert SPEC and SPEC.loader
MIGRATION = module_from_spec(SPEC)
SPEC.loader.exec_module(MIGRATION)
def test_images_column_is_added_only_when_missing():
assert MIGRATION.should_add_images_column(set())
assert not MIGRATION.should_add_images_column({"id", "image", "images"})

View File

@@ -0,0 +1,38 @@
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
MIGRATION_PATH = Path(__file__).parents[1] / "alembic" / "versions" / "0024_vehicle_demand.py"
SPEC = spec_from_file_location("vehicle_demand_migration", MIGRATION_PATH)
assert SPEC and SPEC.loader
MIGRATION = module_from_spec(SPEC)
SPEC.loader.exec_module(MIGRATION)
def test_vehicle_demand_columns_are_added_only_when_missing():
assert MIGRATION.missing_lead_columns(set()) == {
"leadType",
"contactName",
"customerId",
"vehicleDemand",
}
assert MIGRATION.missing_lead_columns({"leadType", "contactName"}) == {
"customerId",
"vehicleDemand",
}
def test_vehicle_demand_indexes_and_foreign_key_are_only_added_when_missing():
assert MIGRATION.missing_named_indexes(set(), MIGRATION.LEAD_INDEXES) == {
"ix_Lead_leadType",
"ix_Lead_customerId",
}
assert not MIGRATION.should_create_customer_fk(
[
{
"referred_table": "Customer",
"referred_columns": ["id"],
"constrained_columns": ["customerId"],
}
]
)

Binary file not shown.

Binary file not shown.

View File

@@ -25,7 +25,7 @@ export default defineConfig(({ mode }) => {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:4001",
target: "http://8.138.234.141:4000",
changeOrigin: true,
},
},