- 为后端Admin新增migration_compat工具,支持离线迁移时的基线资源检查,重构全部alembic迁移脚本适配离线模式,避免重复创建已存在的数据库表和字段 - 完善初始数据库迁移脚本,添加默认部门种子数据,新增ConciergeAdvisor详情字段从JSON到JSONB的类型转换迁移 - 小程序端新增图片资源解析工具resolveNetworkImage,实现本地静态资源到CDN的自动映射,添加图片加载容错降级机制 - 完善HeroSlide类型定义,新增actionLabel字段,优化首页轮播组件的文本渲染与错误处理逻辑 - 新增对应测试用例验证迁移路径正确性与前端数据处理逻辑 - 新增临时 brainstorm UI对比草稿与开发临时状态文件
309 lines
12 KiB
Python
309 lines
12 KiB
Python
"""Create the homepage content domains.
|
|
|
|
Revision ID: 0017_home_content
|
|
Revises: 0016_concierge_advisors
|
|
"""
|
|
|
|
from datetime import datetime
|
|
import json
|
|
|
|
from alembic import context, op
|
|
import sqlalchemy as sa
|
|
from sqlalchemy import inspect
|
|
from sqlalchemy.dialects import postgresql
|
|
|
|
from app.migration_compat import baseline_column_names, baseline_table_names
|
|
|
|
|
|
revision = "0017_home_content"
|
|
down_revision = "0016_concierge_advisors"
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
|
|
SEED_EXPERIENCES = (
|
|
{
|
|
"id": "waterfall-descent",
|
|
"badge": "玩过推荐",
|
|
"category": "瀑降体验",
|
|
"title": "悬崖瀑降",
|
|
"englishTitle": "WATERFALL DESCENT",
|
|
"image": "https://genk.mediacdn.vn/139269124445442048/2024/4/27/10-23-sinkhole-1714189653945948438879.jpg",
|
|
"demandKeyword": "悬崖瀑降",
|
|
"isActive": True,
|
|
"sortOrder": 0,
|
|
},
|
|
{
|
|
"id": "cave-exploration",
|
|
"badge": "玩过推荐",
|
|
"category": "地心探险",
|
|
"title": "森林&探洞",
|
|
"englishTitle": "CAVE EXPLORATION",
|
|
"image": "https://www.zurnal24.si/media/img/5e/d5/9526a56dba168aa136f3.jpeg",
|
|
"demandKeyword": "地心探险",
|
|
"isActive": True,
|
|
"sortOrder": 1,
|
|
},
|
|
)
|
|
|
|
SEED_TEAM_BUILDINGS = (
|
|
{
|
|
"id": "wild-challenge",
|
|
"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,
|
|
"sortOrder": 0,
|
|
},
|
|
{
|
|
"id": "canyon-teamwork",
|
|
"tag": "团队协作",
|
|
"title": "峡谷溯溪,默契同行",
|
|
"description": "溯溪与分组协作,兼顾参与感与安全。",
|
|
"detailSubtitle": "溯溪与分组协作,兼顾参与感与安全。",
|
|
"detailParagraphs": ["溯溪与分组协作,兼顾参与感与安全。"],
|
|
"image": "https://dimg04.c-ctrip.com/images/0EQ5712000ca7t504EC0E_W_640_10000.jpg?proc=autoorient",
|
|
"demandKeyword": "峡谷团建",
|
|
"isActive": True,
|
|
"sortOrder": 1,
|
|
},
|
|
{
|
|
"id": "village-gathering",
|
|
"tag": "人文聚会",
|
|
"title": "苗寨共聚,认识彼此",
|
|
"description": "夜游、长桌宴与文化体验,适合团建收尾。",
|
|
"detailSubtitle": "夜游、长桌宴与文化体验,适合团建收尾。",
|
|
"detailParagraphs": ["夜游、长桌宴与文化体验,适合团建收尾。"],
|
|
"image": "https://p6.itc.cn/q_70/images03/20200918/df728d2b79d943da869333e2ea2c92c8.jpeg",
|
|
"demandKeyword": "贵州团建",
|
|
"isActive": True,
|
|
"sortOrder": 2,
|
|
},
|
|
)
|
|
|
|
|
|
OPTIONAL_SEED_COLUMN_TYPES = {
|
|
"detailSubtitle": sa.String(),
|
|
"detailParagraphs": postgresql.JSONB(astext_type=sa.Text()),
|
|
"images": postgresql.JSONB(astext_type=sa.Text()),
|
|
}
|
|
|
|
|
|
def is_offline_mode() -> bool:
|
|
"""Keep migration helpers directly testable outside Alembic's context proxy."""
|
|
try:
|
|
return context.is_offline_mode()
|
|
except (NameError, AttributeError):
|
|
return False
|
|
|
|
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,
|
|
},
|
|
{
|
|
"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,
|
|
},
|
|
{
|
|
"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,
|
|
},
|
|
{
|
|
"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,
|
|
},
|
|
)
|
|
|
|
|
|
def _create_table(table_name: str, columns: list[sa.Column]) -> None:
|
|
if is_offline_mode():
|
|
if table_name in baseline_table_names():
|
|
return
|
|
op.create_table(table_name, *columns, sa.PrimaryKeyConstraint("id"))
|
|
op.create_index(f"ix_{table_name}_isActive", table_name, ["isActive"])
|
|
op.create_index(f"ix_{table_name}_sortOrder", table_name, ["sortOrder"])
|
|
return
|
|
bind = op.get_bind()
|
|
if table_name in set(inspect(bind).get_table_names()):
|
|
return
|
|
op.create_table(table_name, *columns, sa.PrimaryKeyConstraint("id"))
|
|
op.create_index(f"ix_{table_name}_isActive", table_name, ["isActive"])
|
|
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]:
|
|
prepared_rows = []
|
|
for row in rows:
|
|
values = {**row, "createdAt": timestamp, "updatedAt": timestamp}
|
|
if "detailSubtitle" in available_columns and not values.get("detailSubtitle"):
|
|
values["detailSubtitle"] = values.get("description", "")
|
|
if "detailParagraphs" in available_columns and not values.get("detailParagraphs"):
|
|
values["detailParagraphs"] = [values.get("description", "")]
|
|
if "images" in available_columns and not values.get("images"):
|
|
values["images"] = [values.get("image", "")]
|
|
prepared_rows.append(
|
|
{key: value for key, value in values.items() if key in available_columns}
|
|
)
|
|
return prepared_rows
|
|
|
|
|
|
# Kept for compatibility with the bootstrap migration tests and older tooling.
|
|
_prepare_seed_rows = prepare_seed_rows
|
|
|
|
|
|
def _seed_table(table_name: str, columns: list[sa.Column], rows: tuple[dict, ...]) -> None:
|
|
column_types = {column.name: column.type for column in columns}
|
|
if is_offline_mode():
|
|
available_columns = set(column_types)
|
|
if table_name in baseline_table_names():
|
|
available_columns.update(baseline_column_names(table_name))
|
|
for name, column_type in OPTIONAL_SEED_COLUMN_TYPES.items():
|
|
if name in available_columns:
|
|
column_types.setdefault(name, column_type)
|
|
else:
|
|
bind = op.get_bind()
|
|
current_columns = inspect(bind).get_columns(table_name)
|
|
available_columns = {column["name"] for column in current_columns}
|
|
column_types = {
|
|
column["name"]: column.get("type", sa.String())
|
|
for column in current_columns
|
|
}
|
|
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)
|
|
seed_rows = prepare_seed_rows(rows, seed_column_names, timestamp)
|
|
if is_offline_mode():
|
|
seed_rows = [
|
|
{
|
|
**row,
|
|
**{
|
|
name: json.dumps(row[name], ensure_ascii=False)
|
|
for name in ("detailParagraphs", "images")
|
|
if name in row
|
|
},
|
|
}
|
|
for row in seed_rows
|
|
]
|
|
for name in ("detailParagraphs", "images"):
|
|
if name in column_types:
|
|
column_types[name] = sa.Text()
|
|
table = sa.table(
|
|
table_name,
|
|
*[
|
|
sa.column(name, column_types[name])
|
|
for name in column_types
|
|
if name in seed_column_names
|
|
],
|
|
)
|
|
op.bulk_insert(table, seed_rows)
|
|
|
|
|
|
def upgrade() -> None:
|
|
experience_columns = [
|
|
sa.Column("id", sa.String(), nullable=False),
|
|
sa.Column("badge", sa.String(), nullable=False),
|
|
sa.Column("category", sa.String(), nullable=False),
|
|
sa.Column("title", sa.String(), nullable=False),
|
|
sa.Column("englishTitle", sa.String(), nullable=False),
|
|
sa.Column("image", sa.String(), nullable=False),
|
|
sa.Column("demandKeyword", sa.String(), nullable=False),
|
|
sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.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),
|
|
]
|
|
team_columns = [
|
|
sa.Column("id", sa.String(), nullable=False),
|
|
sa.Column("tag", sa.String(), nullable=False),
|
|
sa.Column("title", sa.String(), nullable=False),
|
|
sa.Column("description", sa.Text(), nullable=False),
|
|
sa.Column("image", sa.String(), nullable=False),
|
|
sa.Column("demandKeyword", sa.String(), nullable=False),
|
|
sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.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),
|
|
]
|
|
archive_columns = [
|
|
sa.Column("id", sa.String(), nullable=False),
|
|
sa.Column("title", sa.String(), nullable=False),
|
|
sa.Column("image", sa.String(), nullable=False),
|
|
sa.Column("demandKeyword", sa.String(), nullable=False),
|
|
sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.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),
|
|
]
|
|
|
|
_create_table("HomeExperience", experience_columns)
|
|
_create_table("HomeTeamBuilding", team_columns)
|
|
_create_table("HomeWildArchive", archive_columns)
|
|
|
|
if is_offline_mode():
|
|
_seed_table("HomeExperience", experience_columns, SEED_EXPERIENCES)
|
|
_seed_table("HomeTeamBuilding", team_columns, SEED_TEAM_BUILDINGS)
|
|
_seed_table("HomeWildArchive", archive_columns, SEED_WILD_ARCHIVES)
|
|
return
|
|
|
|
bind = op.get_bind()
|
|
for table_name, columns, rows in (
|
|
("HomeExperience", experience_columns, SEED_EXPERIENCES),
|
|
("HomeTeamBuilding", team_columns, SEED_TEAM_BUILDINGS),
|
|
("HomeWildArchive", archive_columns, SEED_WILD_ARCHIVES),
|
|
):
|
|
table = sa.table(table_name, sa.column("id", sa.String()))
|
|
if bind.execute(sa.select(table.c.id).limit(1)).first() is None:
|
|
_seed_table(table_name, columns, rows)
|
|
|
|
|
|
def downgrade() -> None:
|
|
bind = op.get_bind()
|
|
existing_tables = set(inspect(bind).get_table_names())
|
|
for table_name in ("HomeWildArchive", "HomeTeamBuilding", "HomeExperience"):
|
|
if table_name in existing_tables:
|
|
op.drop_table(table_name)
|