diff --git a/WonderQ-Admin-UI/.env.example b/WonderQ-Admin-UI/.env.example index 5d2a7c5..352f39a 100644 --- a/WonderQ-Admin-UI/.env.example +++ b/WonderQ-Admin-UI/.env.example @@ -1 +1 @@ -VITE_API_BASE_URL="http://localhost:4000" +VITE_API_BASE_URL="http://8.138.234.141:4000" diff --git a/WonderQ-Admin-UI/vite.config.ts b/WonderQ-Admin-UI/vite.config.ts index 6c95853..6a5094d 100644 --- a/WonderQ-Admin-UI/vite.config.ts +++ b/WonderQ-Admin-UI/vite.config.ts @@ -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, }, }); - diff --git a/WonderQ-Admin/alembic/versions/0017_home_content.py b/WonderQ-Admin/alembic/versions/0017_home_content.py index 7ca0b22..6775559 100644 --- a/WonderQ-Admin/alembic/versions/0017_home_content.py +++ b/WonderQ-Admin/alembic/versions/0017_home_content.py @@ -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: diff --git a/WonderQ-Admin/alembic/versions/0019_home_wild_archive_images.py b/WonderQ-Admin/alembic/versions/0019_home_wild_archive_images.py index ff3ae28..be437e9 100644 --- a/WonderQ-Admin/alembic/versions/0019_home_wild_archive_images.py +++ b/WonderQ-Admin/alembic/versions/0019_home_wild_archive_images.py @@ -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: diff --git a/WonderQ-Admin/alembic/versions/0020_home_team_building_details.py b/WonderQ-Admin/alembic/versions/0020_home_team_building_details.py index f3ac483..35ac7cb 100644 --- a/WonderQ-Admin/alembic/versions/0020_home_team_building_details.py +++ b/WonderQ-Admin/alembic/versions/0020_home_team_building_details.py @@ -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: diff --git a/WonderQ-Admin/alembic/versions/0021_detail_records.py b/WonderQ-Admin/alembic/versions/0021_detail_records.py index e9e5c22..51abf0f 100644 --- a/WonderQ-Admin/alembic/versions/0021_detail_records.py +++ b/WonderQ-Admin/alembic/versions/0021_detail_records.py @@ -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: diff --git a/WonderQ-Admin/alembic/versions/0023_detail_concierge_advisor.py b/WonderQ-Admin/alembic/versions/0023_detail_concierge_advisor.py index 80390c9..700547e 100644 --- a/WonderQ-Admin/alembic/versions/0023_detail_concierge_advisor.py +++ b/WonderQ-Admin/alembic/versions/0023_detail_concierge_advisor.py @@ -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") diff --git a/WonderQ-Admin/alembic/versions/0024_vehicle_demand.py b/WonderQ-Admin/alembic/versions/0024_vehicle_demand.py index f15878b..a40d199 100644 --- a/WonderQ-Admin/alembic/versions/0024_vehicle_demand.py +++ b/WonderQ-Admin/alembic/versions/0024_vehicle_demand.py @@ -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: diff --git a/WonderQ-Admin/tests/test_detail_concierge_migration.py b/WonderQ-Admin/tests/test_detail_concierge_migration.py new file mode 100644 index 0000000..159492e --- /dev/null +++ b/WonderQ-Admin/tests/test_detail_concierge_migration.py @@ -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"}) diff --git a/WonderQ-Admin/tests/test_detail_records_migration.py b/WonderQ-Admin/tests/test_detail_records_migration.py new file mode 100644 index 0000000..506eb21 --- /dev/null +++ b/WonderQ-Admin/tests/test_detail_records_migration.py @@ -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)) == [] diff --git a/WonderQ-Admin/tests/test_home_content_migration.py b/WonderQ-Admin/tests/test_home_content_migration.py new file mode 100644 index 0000000..d5f11ed --- /dev/null +++ b/WonderQ-Admin/tests/test_home_content_migration.py @@ -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"]] diff --git a/WonderQ-Admin/tests/test_home_team_building_details_migration.py b/WonderQ-Admin/tests/test_home_team_building_details_migration.py new file mode 100644 index 0000000..b86ddf8 --- /dev/null +++ b/WonderQ-Admin/tests/test_home_team_building_details_migration.py @@ -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() diff --git a/WonderQ-Admin/tests/test_home_wild_archive_images_migration.py b/WonderQ-Admin/tests/test_home_wild_archive_images_migration.py new file mode 100644 index 0000000..99befc2 --- /dev/null +++ b/WonderQ-Admin/tests/test_home_wild_archive_images_migration.py @@ -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"}) diff --git a/WonderQ-Admin/tests/test_vehicle_demand_migration.py b/WonderQ-Admin/tests/test_vehicle_demand_migration.py new file mode 100644 index 0000000..816cfeb --- /dev/null +++ b/WonderQ-Admin/tests/test_vehicle_demand_migration.py @@ -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"], + } + ] + ) diff --git a/WonderQ-Admin/wonderq-admin-release-20260823-01.tar b/WonderQ-Admin/wonderq-admin-release-20260823-01.tar new file mode 100644 index 0000000..7f74260 Binary files /dev/null and b/WonderQ-Admin/wonderq-admin-release-20260823-01.tar differ diff --git a/WonderQ-Admin/wonderq-local-data.dump b/WonderQ-Admin/wonderq-local-data.dump new file mode 100644 index 0000000..0eec2f5 Binary files /dev/null and b/WonderQ-Admin/wonderq-local-data.dump differ diff --git a/WonderQ-MiniAPP/vite.config.ts b/WonderQ-MiniAPP/vite.config.ts index 92c9750..ab80c1a 100644 --- a/WonderQ-MiniAPP/vite.config.ts +++ b/WonderQ-MiniAPP/vite.config.ts @@ -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, }, },