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 committed 2026-08-25 19:17:31 +08:00
1 parent d42f69bd47
commit 203781210c
17 files changed
+519 -120

No files matched your search

+1 -1
View File
@@ -1 +1 @@
VITE_API_BASE_URL="http://localhost:4000" VITE_API_BASE_URL="http://8.138.234.141:4000"
+1 -2
View File
@@ -19,7 +19,7 @@ export default defineConfig({
}, },
proxy: { proxy: {
"/api": { "/api": {
target: "http://localhost:4001", target: "http://8.138.234.141:4000",
changeOrigin: true, changeOrigin: true,
}, },
}, },
@@ -30,4 +30,3 @@ export default defineConfig({
strictPort: true, strictPort: true,
}, },
}); });
@@ -9,6 +9,7 @@ from datetime import datetime
from alembic import context, op from alembic import context, op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect from sqlalchemy import inspect
from sqlalchemy.dialects import postgresql
revision = "0017_home_content" revision = "0017_home_content"
@@ -48,6 +49,8 @@ SEED_TEAM_BUILDINGS = (
"tag": "户外挑战", "tag": "户外挑战",
"title": "山野挑战,共创极境", "title": "山野挑战,共创极境",
"description": "洞穴、瀑降与协作,适合 10-30 人。", "description": "洞穴、瀑降与协作,适合 10-30 人。",
"detailSubtitle": "洞穴、瀑降与协作,适合 10-30 人。",
"detailParagraphs": ["洞穴、瀑降与协作,适合 10-30 人。"],
"image": "https://dimg04.c-ctrip.com/images/1mh0412000njfr1ot9453_W_640_10000.jpg?proc=autoorient", "image": "https://dimg04.c-ctrip.com/images/1mh0412000njfr1ot9453_W_640_10000.jpg?proc=autoorient",
"demandKeyword": "户外团建", "demandKeyword": "户外团建",
"isActive": True, "isActive": True,
@@ -58,6 +61,8 @@ SEED_TEAM_BUILDINGS = (
"tag": "团队协作", "tag": "团队协作",
"title": "峡谷溯溪,默契同行", "title": "峡谷溯溪,默契同行",
"description": "溯溪与分组协作,兼顾参与感与安全。", "description": "溯溪与分组协作,兼顾参与感与安全。",
"detailSubtitle": "溯溪与分组协作,兼顾参与感与安全。",
"detailParagraphs": ["溯溪与分组协作,兼顾参与感与安全。"],
"image": "https://dimg04.c-ctrip.com/images/0EQ5712000ca7t504EC0E_W_640_10000.jpg?proc=autoorient", "image": "https://dimg04.c-ctrip.com/images/0EQ5712000ca7t504EC0E_W_640_10000.jpg?proc=autoorient",
"demandKeyword": "峡谷团建", "demandKeyword": "峡谷团建",
"isActive": True, "isActive": True,
@@ -68,6 +73,8 @@ SEED_TEAM_BUILDINGS = (
"tag": "人文聚会", "tag": "人文聚会",
"title": "苗寨共聚,认识彼此", "title": "苗寨共聚,认识彼此",
"description": "夜游、长桌宴与文化体验,适合团建收尾。", "description": "夜游、长桌宴与文化体验,适合团建收尾。",
"detailSubtitle": "夜游、长桌宴与文化体验,适合团建收尾。",
"detailParagraphs": ["夜游、长桌宴与文化体验,适合团建收尾。"],
"image": "https://p6.itc.cn/q_70/images03/20200918/df728d2b79d943da869333e2ea2c92c8.jpeg", "image": "https://p6.itc.cn/q_70/images03/20200918/df728d2b79d943da869333e2ea2c92c8.jpeg",
"demandKeyword": "贵州团建", "demandKeyword": "贵州团建",
"isActive": True, "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 = ( SEED_WILD_ARCHIVES = (
{ {
"id": "hundred-meter-descent", "id": "hundred-meter-descent",
"title": "百米自降", "title": "百米自降",
"image": "https://genk.mediacdn.vn/139269124445442048/2024/4/27/10-23-sinkhole-1714189653945948438879.jpg", "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": "悬崖瀑降", "demandKeyword": "悬崖瀑降",
"isActive": True, "isActive": True,
"sortOrder": 0, "sortOrder": 0,
@@ -88,6 +105,7 @@ SEED_WILD_ARCHIVES = (
"id": "shilong-cave", "id": "shilong-cave",
"title": "石龙洞", "title": "石龙洞",
"image": "https://www.zurnal24.si/media/img/5e/d5/9526a56dba168aa136f3.jpeg", "image": "https://www.zurnal24.si/media/img/5e/d5/9526a56dba168aa136f3.jpeg",
"images": ["https://www.zurnal24.si/media/img/5e/d5/9526a56dba168aa136f3.jpeg"],
"demandKeyword": "地心探险", "demandKeyword": "地心探险",
"isActive": True, "isActive": True,
"sortOrder": 1, "sortOrder": 1,
@@ -96,6 +114,7 @@ SEED_WILD_ARCHIVES = (
"id": "cliff-current", "id": "cliff-current",
"title": "绝壁迎流", "title": "绝壁迎流",
"image": "https://q9.itc.cn/q_70/images03/20250810/b62f5afc191a4947a66e6b32721b0235.jpeg", "image": "https://q9.itc.cn/q_70/images03/20250810/b62f5afc191a4947a66e6b32721b0235.jpeg",
"images": ["https://q9.itc.cn/q_70/images03/20250810/b62f5afc191a4947a66e6b32721b0235.jpeg"],
"demandKeyword": "峡谷探险", "demandKeyword": "峡谷探险",
"isActive": True, "isActive": True,
"sortOrder": 2, "sortOrder": 2,
@@ -104,6 +123,9 @@ SEED_WILD_ARCHIVES = (
"id": "canyon-streaming", "id": "canyon-streaming",
"title": "峡谷溯溪", "title": "峡谷溯溪",
"image": "https://dimg04.c-ctrip.com/images/0EQ5712000ca7t504EC0E_W_640_10000.jpg?proc=autoorient", "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": "峡谷溯溪", "demandKeyword": "峡谷溯溪",
"isActive": True, "isActive": True,
"sortOrder": 3, "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"]) 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: 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]) column_types = {column.name: column.type for column in columns}
timestamp = datetime(2026, 1, 1) if context.is_offline_mode():
op.bulk_insert( available_columns = set(column_types)
table, else:
[{**row, "createdAt": timestamp, "updatedAt": timestamp} for row in rows], 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: def upgrade() -> None:
@@ -4,8 +4,9 @@ Revision ID: 0019_home_wild_archive_images
Revises: 0018_home_wanfa_recommendations Revises: 0018_home_wanfa_recommendations
""" """
from alembic import op from alembic import context, op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
@@ -15,22 +16,40 @@ branch_labels = None
depends_on = None depends_on = None
def should_add_images_column(existing_columns: set[str]) -> bool:
return "images" not in existing_columns
def upgrade() -> None: def upgrade() -> None:
op.add_column( if context.is_offline_mode():
"HomeWildArchive", add_images_column = True
sa.Column( else:
"images", bind = op.get_bind()
postgresql.JSONB(astext_type=sa.Text()), existing_columns = {column["name"] for column in inspect(bind).get_columns("HomeWildArchive")}
nullable=False, add_images_column = should_add_images_column(existing_columns)
server_default=sa.text("'[]'::jsonb"),
), 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( op.execute(
sa.text( 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: def downgrade() -> None:
@@ -4,8 +4,9 @@ Revision ID: 0020_home_team_building_details
Revises: 0019_home_wild_archive_images Revises: 0019_home_wild_archive_images
""" """
from alembic import op from alembic import context, op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
@@ -15,35 +16,59 @@ branch_labels = None
depends_on = 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: def upgrade() -> None:
op.add_column( if context.is_offline_mode():
"HomeTeamBuilding", columns_to_add = DETAIL_COLUMNS
sa.Column("detailSubtitle", sa.String(), nullable=False, server_default=""), else:
) bind = op.get_bind()
op.add_column( existing_columns = {column["name"] for column in inspect(bind).get_columns("HomeTeamBuilding")}
"HomeTeamBuilding", columns_to_add = missing_detail_columns(existing_columns)
sa.Column(
"detailParagraphs", if "detailSubtitle" in columns_to_add:
postgresql.JSONB(astext_type=sa.Text()), op.add_column(
nullable=False, "HomeTeamBuilding",
server_default=sa.text("'[]'::jsonb"), 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( op.execute(
sa.text( sa.text(
'UPDATE "HomeTeamBuilding" ' 'UPDATE "HomeTeamBuilding" '
'SET "detailSubtitle" = CASE ' 'SET "detailSubtitle" = CASE '
'WHEN "detailSubtitle" = \'\' THEN "description" ' 'WHEN COALESCE("detailSubtitle", \'\') = \'\' THEN "description" '
'ELSE "detailSubtitle" END, ' 'ELSE "detailSubtitle" END, '
'"detailParagraphs" = CASE ' '"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") ' 'THEN jsonb_build_array("description") '
'ELSE "detailParagraphs" END ' '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) if "detailSubtitle" in columns_to_add:
op.alter_column("HomeTeamBuilding", "detailParagraphs", server_default=None) 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: def downgrade() -> None:
@@ -6,8 +6,9 @@ Revises: 0020_home_team_building_details
from datetime import datetime from datetime import datetime
from alembic import op from alembic import context, op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
@@ -17,30 +18,80 @@ branch_labels = None
depends_on = 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: def upgrade() -> None:
op.create_table( if context.is_offline_mode():
"DetailRecord", table_exists = False
sa.Column("id", sa.String(), nullable=False), existing_index_names = set()
sa.Column("key", sa.String(), nullable=False), else:
sa.Column("eyebrow", sa.String(), nullable=False), bind = op.get_bind()
sa.Column("duration", sa.String(), nullable=False), inspector = inspect(bind)
sa.Column("title", sa.String(), nullable=False), table_exists = "DetailRecord" in set(inspector.get_table_names())
sa.Column("subtitle", sa.Text(), nullable=False), existing_index_names = {
sa.Column("intro", sa.Text(), nullable=False), index["name"]
sa.Column("highlights", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")), for index in inspector.get_indexes("DetailRecord")
sa.Column("included", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")), } if table_exists else set()
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")), if not table_exists:
sa.Column("gallery", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")), op.create_table(
sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.text("true")), "DetailRecord",
sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"), sa.Column("id", sa.String(), nullable=False),
sa.Column("createdAt", sa.DateTime(), nullable=False), sa.Column("key", sa.String(), nullable=False),
sa.Column("updatedAt", sa.DateTime(), nullable=False), sa.Column("eyebrow", sa.String(), nullable=False),
sa.PrimaryKeyConstraint("id"), sa.Column("duration", sa.String(), nullable=False),
sa.UniqueConstraint("key"), sa.Column("title", sa.String(), nullable=False),
) sa.Column("subtitle", sa.Text(), nullable=False),
op.create_index("ix_DetailRecord_isActive", "DetailRecord", ["isActive"]) sa.Column("intro", sa.Text(), nullable=False),
op.create_index("ix_DetailRecord_sortOrder", "DetailRecord", ["sortOrder"]) 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( detail_table = sa.table(
"DetailRecord", "DetailRecord",
@@ -61,34 +112,18 @@ def upgrade() -> None:
sa.column("createdAt", sa.DateTime()), sa.column("createdAt", sa.DateTime()),
sa.column("updatedAt", 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') sa.text('SELECT "id", "title", "subtitle", "image", "sortOrder" FROM "WanfaRoute" ORDER BY "sortOrder" ASC')
).mappings().all() ).mappings().all()
timestamp = datetime(2026, 1, 1) existing_keys = set(conn.execute(sa.text('SELECT "key" FROM "DetailRecord"')).scalars())
op.bulk_insert( seed_rows = build_detail_seed_rows(routes, existing_keys, timestamp)
detail_table, if seed_rows:
[ op.bulk_insert(detail_table, seed_rows)
{
"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
],
)
def downgrade() -> None: def downgrade() -> None:
@@ -4,8 +4,9 @@ Revision ID: 0023_detail_concierge_advisor
Revises: 0022_opaque_ids Revises: 0022_opaque_ids
""" """
from alembic import op from alembic import context, op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect
revision = "0023_detail_concierge_advisor" revision = "0023_detail_concierge_advisor"
@@ -14,9 +15,32 @@ branch_labels = None
depends_on = None depends_on = None
def should_add_concierge_advisor_column(existing_columns: set[str]) -> bool:
return "conciergeAdvisorId" not in existing_columns
def upgrade() -> None: 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: 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")
@@ -1,7 +1,8 @@
"""Add vehicle demand fields and service configuration.""" """Add vehicle demand fields and service configuration."""
from alembic import op from alembic import context, op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
@@ -11,34 +12,115 @@ branch_labels = None
depends_on = None depends_on = None
def upgrade() -> None: LEAD_COLUMNS = {
op.add_column("Lead", sa.Column("leadType", sa.String(), nullable=False, server_default="general")) "leadType",
op.add_column("Lead", sa.Column("contactName", sa.String(), nullable=True)) "contactName",
op.add_column("Lead", sa.Column("customerId", sa.String(), nullable=True)) "customerId",
op.add_column("Lead", sa.Column("vehicleDemand", postgresql.JSONB(astext_type=sa.Text()), nullable=True)) "vehicleDemand",
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)
op.create_table( LEAD_INDEXES = {
"VehicleServiceConfig", "ix_Lead_leadType": ["leadType"],
sa.Column("id", sa.String(), nullable=False), "ix_Lead_customerId": ["customerId"],
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")), VEHICLE_SERVICE_INDEXES = {
sa.Column("advantages", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")), "ix_VehicleServiceConfig_isActive": ["isActive"],
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), def missing_lead_columns(existing_columns: set[str]) -> set[str]:
sa.PrimaryKeyConstraint("id"), 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) def upgrade() -> None:
op.alter_column("VehicleServiceConfig", "processSteps", server_default=None) if context.is_offline_mode():
op.alter_column("VehicleServiceConfig", "isActive", server_default=None) 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: def downgrade() -> None:
@@ -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"})
@@ -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)) == []
@@ -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"]]
@@ -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()
@@ -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"})
@@ -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.
+1 -1
View File
@@ -25,7 +25,7 @@ export default defineConfig(({ mode }) => {
port: 5173, port: 5173,
proxy: { proxy: {
"/api": { "/api": {
target: "http://localhost:4001", target: "http://8.138.234.141:4000",
changeOrigin: true, changeOrigin: true,
}, },
}, },