diff --git a/alembic/versions/0006_hotel_group_offer_fields.py b/alembic/versions/0006_hotel_group_offer_fields.py new file mode 100644 index 0000000..28627b2 --- /dev/null +++ b/alembic/versions/0006_hotel_group_offer_fields.py @@ -0,0 +1,55 @@ +"""Add offer display fields to hotel groups. + +Revision ID: 0006_hotel_group_offer_fields +Revises: 0005_hotel_vehicle_modules +Create Date: 2026-07-03 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect +from sqlalchemy.dialects import postgresql + + +revision = "0006_hotel_group_offer_fields" +down_revision = "0005_hotel_vehicle_modules" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + if "HotelGroup" not in set(inspect(bind).get_table_names()): + return + + columns = {column["name"] for column in inspect(bind).get_columns("HotelGroup")} + if "coverImage" not in columns: + op.add_column("HotelGroup", sa.Column("coverImage", sa.String(), nullable=True)) + op.execute('UPDATE "HotelGroup" SET "coverImage" = image WHERE "coverImage" IS NULL') + if "priceAmount" not in columns: + op.add_column("HotelGroup", sa.Column("priceAmount", sa.Integer(), nullable=True)) + if "priceUnit" not in columns: + op.add_column("HotelGroup", sa.Column("priceUnit", sa.String(), nullable=True, server_default="起/晚")) + if "tags" not in columns: + op.add_column( + "HotelGroup", + sa.Column( + "tags", + postgresql.ARRAY(sa.String()), + nullable=False, + server_default=sa.text("'{}'::varchar[]"), + ), + ) + if "status" not in columns: + op.add_column("HotelGroup", sa.Column("status", sa.String(), nullable=False, server_default="published")) + + +def downgrade() -> None: + bind = op.get_bind() + if "HotelGroup" not in set(inspect(bind).get_table_names()): + return + + columns = {column["name"] for column in inspect(bind).get_columns("HotelGroup")} + for column_name in ["status", "tags", "priceUnit", "priceAmount", "coverImage"]: + if column_name in columns: + op.drop_column("HotelGroup", column_name) \ No newline at end of file diff --git a/app/models.py b/app/models.py index 4cbbad3..2c55cc3 100644 --- a/app/models.py +++ b/app/models.py @@ -133,6 +133,11 @@ class HotelGroup(Base): title: Mapped[str] = mapped_column(String, nullable=False) description: Mapped[str | None] = mapped_column(Text) image: Mapped[str | None] = mapped_column(String) + coverImage: Mapped[str | None] = mapped_column(String) + priceAmount: Mapped[int | None] = mapped_column(Integer) + priceUnit: Mapped[str | None] = mapped_column(String, default="起/晚") + tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list, nullable=False) + status: Mapped[str] = mapped_column(String, default="published", nullable=False) sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False) isActive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False) diff --git a/app/routers/admin.py b/app/routers/admin.py index f8f8d03..3c351a5 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -36,7 +36,7 @@ from ..models import ( from ..schemas import AdminProductQuery, LeadQuery, LeadStatus, LeadStatusIn, LoginIn, ProductCreateIn, ProductStatus, ProductUpdateIn, SiteConfigPatchIn, SiteConfigReorderIn from ..seed import create_media, reset_guizhou_content from ..route_sections import replace_route_section_products, route_section_dict, route_section_query -from ..serializers import admin_product_dict, destination_dict, encode_value, lead_dict, model_dict +from ..serializers import admin_product_dict, destination_dict, encode_value, hotel_group_dict, lead_dict, model_dict from .shared import site_config @@ -109,10 +109,10 @@ SITE_CONFIG_MODULES = { "model": HotelGroup, "entity": "hotel_group", "primary": "title", - "fields": {"title", "description", "image", "isActive", "sortOrder"}, + "fields": {"title", "description", "image", "coverImage", "priceAmount", "priceUnit", "tags", "status", "isActive", "sortOrder"}, "none_to_empty": set(), - "empty_to_none": {"description", "image"}, - "create_defaults": {"description": None, "image": None}, + "empty_to_none": {"description", "image", "coverImage", "priceUnit"}, + "create_defaults": {"description": None, "image": None, "coverImage": None, "priceAmount": None, "priceUnit": "起/晚", "tags": [], "status": "published"}, }, "vehicleOptions": { "model": VehicleOption, @@ -133,7 +133,7 @@ SITE_CONFIG_MODULES = { }, } -CAMPAIGN_STATUSES = {"draft", "published"} +PUBLISH_STATUSES = {"draft", "published"} def media_error(status_code: int, message: str, code: str, details: dict | None = None) -> None: @@ -329,7 +329,7 @@ def normalize_campaign_tags(value) -> list[str]: return [] tags = [item.strip() for item in value if isinstance(item, str) and item.strip()] if len(tags) > 3: - site_config_error(422, "campaign tags cannot exceed 3", "MODULE_CONFIG_VALIDATION_ERROR", {"field": "tags", "max": 3}) + site_config_error(422, "tags cannot exceed 3", "MODULE_CONFIG_VALIDATION_ERROR", {"field": "tags", "max": 3}) return tags @@ -344,9 +344,9 @@ def site_field_value(config: dict, field: str, value): return value -def validate_campaign_status(value): +def validate_publish_status(value): status_value = clean_site_value(value) - if status_value not in CAMPAIGN_STATUSES: + if status_value not in PUBLISH_STATUSES: site_config_error(422, "status must be draft or published", "MODULE_CONFIG_VALIDATION_ERROR", {"field": "status"}) return status_value @@ -394,6 +394,8 @@ def site_item_dict(module: str, item) -> dict: return map_image_admin_dict(item) if module == "routeSections": return route_section_dict(item) + if module == "hotelGroups": + return hotel_group_dict(item) return destination_dict(item) if module == "destinations" else model_dict(item) @@ -438,7 +440,19 @@ def site_create_payload(module: str, config: dict, body: SiteConfigPatchIn, db: payload["slug"] = slug if not payload.get("priceUnit"): payload["priceUnit"] = "起/人" - payload["status"] = validate_campaign_status(payload.get("status", "draft")) + payload["status"] = validate_publish_status(payload.get("status", "draft")) + if module == "hotelGroups": + if not payload.get("priceUnit"): + payload["priceUnit"] = "起/晚" + payload["status"] = validate_publish_status(payload.get("status", "published")) + if not payload.get("coverImage") and payload.get("image"): + payload["coverImage"] = payload["image"] + if not payload.get("image") and payload.get("coverImage"): + payload["image"] = payload["coverImage"] + if "status" in fields and "isActive" not in fields: + payload["isActive"] = payload["status"] == "published" + if "isActive" in fields and "status" not in fields: + payload["status"] = "published" if payload.get("isActive") else "draft" return payload @@ -456,9 +470,18 @@ def apply_site_patch(module: str, config: dict, item, body: SiteConfigPatchIn) - site_config_error(422, "必填字段不能为空", "MODULE_CONFIG_VALIDATION_ERROR", {"field": field}) if module == "campaigns" and field == "slug" and not value: site_config_error(422, "required field is empty", "MODULE_CONFIG_VALIDATION_ERROR", {"field": field}) - if module == "campaigns" and field == "status": - value = validate_campaign_status(value) + if module in {"campaigns", "hotelGroups"} and field == "status": + value = validate_publish_status(value) setattr(item, field, value) + if module == "hotelGroups": + if "coverImage" in fields and "image" not in fields: + item.image = item.coverImage + if "image" in fields and "coverImage" not in fields: + item.coverImage = item.image + if "status" in fields and "isActive" not in fields: + item.isActive = item.status == "published" + if "isActive" in fields and "status" not in fields: + item.status = "published" if item.isActive else "draft" def create_route_section_config(body: SiteConfigPatchIn, request: Request, db: Session) -> dict: @@ -650,7 +673,7 @@ def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = D ], "routeSections": [route_section_dict(item) for item in db.scalars(route_section_query()).all()], "hotelGroups": [ - model_dict(item) + hotel_group_dict(item) for item in db.scalars(select(HotelGroup).order_by(HotelGroup.sortOrder.asc())).all() ], "vehicleOptions": [ diff --git a/app/routers/shared.py b/app/routers/shared.py index cbdd44a..7b9f2bd 100644 --- a/app/routers/shared.py +++ b/app/routers/shared.py @@ -2,7 +2,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session, selectinload from ..models import Campaign, CtaBanner, Destination, HeroSlide, HotelGroup, MapImage, ThemeCard, VehicleOption from ..route_sections import route_section_dict, route_section_query -from ..serializers import destination_dict, model_dict +from ..serializers import destination_dict, hotel_group_dict, model_dict def site_config(db: Session, active_only: bool, include_public_extras: bool = True) -> dict: @@ -19,7 +19,7 @@ def site_config(db: Session, active_only: bool, include_public_extras: bool = Tr map_stmt = map_stmt.where(MapImage.isActive.is_(True)) theme_stmt = theme_stmt.where(ThemeCard.isActive.is_(True)) cta_stmt = cta_stmt.where(CtaBanner.isActive.is_(True)) - hotel_stmt = hotel_stmt.where(HotelGroup.isActive.is_(True)) + hotel_stmt = hotel_stmt.where(HotelGroup.isActive.is_(True), HotelGroup.status == "published") vehicle_stmt = vehicle_stmt.where(VehicleOption.isActive.is_(True)) hero_slides = db.scalars(hero_stmt).all() @@ -41,6 +41,6 @@ def site_config(db: Session, active_only: bool, include_public_extras: bool = Tr route_sections = [section for section in route_sections if section.isActive] result["campaigns"] = [model_dict(item) for item in campaigns] result["routeSections"] = [route_section_dict(item, public=True) for item in route_sections] - result["hotelGroups"] = [model_dict(item) for item in db.scalars(hotel_stmt).all()] + result["hotelGroups"] = [hotel_group_dict(item) for item in db.scalars(hotel_stmt).all()] result["vehicleOptions"] = [model_dict(item) for item in db.scalars(vehicle_stmt).all()] return result \ No newline at end of file diff --git a/app/seed.py b/app/seed.py index e7aaa08..af6aa69 100644 --- a/app/seed.py +++ b/app/seed.py @@ -145,6 +145,12 @@ def reset_guizhou_content(db: Session) -> dict: title=group["title"], description=group.get("description"), image=group.get("image"), + coverImage=group.get("coverImage") or group.get("image"), + priceAmount=group.get("priceAmount"), + priceUnit=group.get("priceUnit") or "起/晚", + tags=group.get("tags", []), + status=group.get("status", "published"), + isActive=group.get("isActive", True), sortOrder=index, ) ) diff --git a/app/serializers.py b/app/serializers.py index 00c2f47..4cb6b7f 100644 --- a/app/serializers.py +++ b/app/serializers.py @@ -70,6 +70,16 @@ def product_dict(product) -> dict: return admin_product_dict(product) +def hotel_group_dict(group) -> dict: + data = model_dict(group) + if not data.get("coverImage"): + data["coverImage"] = data.get("image") + if not data.get("image"): + data["image"] = data.get("coverImage") + data["tags"] = list(group.tags or []) + return data + + def destination_dict(destination, include_count: bool = False, product_count: int | None = None) -> dict: data = model_dict(destination, {"aliases": [model_dict(alias) for alias in _sorted_aliases(destination)]}) if include_count: diff --git a/docs/admin-ui-api-requirements.md b/docs/admin-ui-api-requirements.md index 31172a3..88513fd 100644 --- a/docs/admin-ui-api-requirements.md +++ b/docs/admin-ui-api-requirements.md @@ -214,6 +214,11 @@ type SiteConfig = { title: string; description?: string | null; image?: string | null; + coverImage?: string | null; + priceAmount?: number | null; + priceUnit?: string | null; + tags?: string[]; + status: "draft" | "published"; isActive: boolean; sortOrder: number; createdAt?: string; @@ -280,7 +285,7 @@ type SiteItemPatch = { | `themes` | `label`、`image`、`targetType`、`targetValue`、`isActive` | | `campaigns` | `title`、`description`、`coverImage`、`priceAmount`、`priceUnit`、`tags`、`status` | | `routeSections` | `title`、`subtitle`、`productIds`、`isActive`、`sortOrder` | -| `hotelGroups` | `title`、`description`、`image`、`isActive`、`sortOrder` | +| `hotelGroups` | `title`、`description`、`image`、`coverImage`、`priceAmount`、`priceUnit`、`tags`、`status`、`isActive`、`sortOrder` | | `vehicleOptions` | `title`、`description`、`image`、`isActive`、`sortOrder` | | `ctaBanners` | `alt`(服务标题)、`image`、`targetType`、`targetValue`、`isActive`、`sortOrder` | @@ -483,16 +488,32 @@ PATCH /api/admin/site-config/routeSections/reorder - `DELETE /api/admin/site-config/routeSections/{section_id}` 只删除分组配置并解除关联,不删除商品本体;删除后后端重新整理剩余分组 `sortOrder`。 - `PATCH /api/admin/site-config/routeSections/reorder` 的 `itemIds` 必须完整覆盖当前全部分组 ID,不能缺失、重复或包含未知 ID。 - `GET /api/public/site-config` 只返回启用分组,且 `productIds` 只包含已发布商品;未发布、归档或不存在的商品不进入 Public 响应。 -#### 特色酒店和万趣用车 `hotelGroups` / `vehicleOptions` +#### 特色酒店 `hotelGroups` -`hotelGroups` 是首页“特色酒店”卡片配置,`vehicleOptions` 是首页“万趣用车”卡片配置。两者只维护首页模块卡片,不维护商品本体或商品关联。 +`hotelGroups` 对应首页“特色酒店”模块。该模块已按“特价优惠”的数据配置方式调整,运营可以新增、编辑、删除、排序酒店卡片,并维护标题、描述、价格、标签、封面图和前台展示状态。它只维护首页酒店卡片,不绑定商品本体,也不读取线路商品关联。 ```http POST /api/admin/site-config/hotelGroups PATCH /api/admin/site-config/hotelGroups/{item_id} DELETE /api/admin/site-config/hotelGroups/{item_id} PATCH /api/admin/site-config/hotelGroups/reorder +``` +字段规则: +- 新增请求至少包含 `title`,可包含 `description`、`image`、`coverImage`、`priceAmount`、`priceUnit`、`tags`、`status`、`isActive`、`sortOrder`。 +- 更新请求可包含 `title`、`description`、`image`、`coverImage`、`priceAmount`、`priceUnit`、`tags`、`status`、`isActive`、`sortOrder`。 +- `coverImage` 是酒店封面图主字段;为兼容旧前端,后端同时保留 `image`。当请求只传其中一个字段时,后端应同步另一个字段。 +- `priceAmount` 为价格数值,`priceUnit` 为价格单位文案,默认建议为 `起/晚`。 +- `tags` 最多 3 个,保存时去掉空标签。 +- `status` 只允许 `draft`、`published`;管理端“前台启用”开关会同步提交 `status` 与 `isActive`。Public API 只返回 `status="published"` 且 `isActive=true` 的酒店卡片。 +- 删除只删除首页酒店卡片配置,不删除素材库资源;删除后后端重新整理剩余项 `sortOrder`。 +- `PATCH /reorder` 的 `itemIds` 必须完整覆盖当前 `hotelGroups` 全部配置项 ID,不能缺失、重复或包含未知 ID。 + +#### 万趣用车 `vehicleOptions` + +`vehicleOptions` 对应首页“万趣用车”模块,继续作为普通首页内容卡片维护,不复用酒店价格和标签字段。 + +```http POST /api/admin/site-config/vehicleOptions PATCH /api/admin/site-config/vehicleOptions/{item_id} DELETE /api/admin/site-config/vehicleOptions/{item_id} @@ -502,9 +523,9 @@ PATCH /api/admin/site-config/vehicleOptions/reorder 字段规则: - 新增请求至少包含 `title`,可包含 `description`、`image`、`isActive`、`sortOrder`。 - 更新请求可包含 `title`、`description`、`image`、`isActive`、`sortOrder`。 -- 删除只删除首页卡片配置,不删除商品、目的地或素材库资源;删除后后端重新整理剩余项 `sortOrder`。 -- `PATCH /reorder` 的 `itemIds` 必须完整覆盖当前同模块全部配置项 ID,不能缺失、重复或包含未知 ID。 -- `GET /api/public/site-config` 只返回启用卡片,并按 `sortOrder` 升序;MiniAPP 在字段缺失或空数组时使用本地内容兜底。 +- 删除只删除首页用车卡片配置,不删除素材库资源;删除后后端重新整理剩余项 `sortOrder`。 +- `PATCH /reorder` 的 `itemIds` 必须完整覆盖当前 `vehicleOptions` 全部配置项 ID,不能缺失、重复或包含未知 ID。 +- `GET /api/public/site-config` 只返回启用用车卡片,并按 `sortOrder` 升序;MiniAPP 在字段缺失或空数组时使用本地内容兜底。 #### 更多服务 `ctaBanners` diff --git a/docs/miniapp-public-api.md b/docs/miniapp-public-api.md index e78be62..81809ff 100644 --- a/docs/miniapp-public-api.md +++ b/docs/miniapp-public-api.md @@ -101,7 +101,7 @@ Public API 输出规则: ### `HomeCard` -`HomeCard` 用于首页“特色酒店”与“万趣用车”两个普通卡片模块。MiniAPP 只消费卡片展示字段,不在这两个模块里读取商品本体或线路商品关联。 +`HomeCard` 用于首页“万趣用车”等普通内容卡片模块。MiniAPP 只消费卡片展示字段,不在这些模块里读取商品本体或线路商品关联。 | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | @@ -112,10 +112,30 @@ Public API 输出规则: | `isActive` | `boolean` | 否 | 是否启用;Public API 通常只返回启用卡片 | | `sortOrder` | `number` | 否 | 后台展示顺序;Public API 按该字段升序输出 | +### `HotelCard` + +`HotelCard` 用于首页“特色酒店”模块。该模块按管理端“特价优惠”同类配置方式维护标题、描述、价格、标签、封面图和发布状态,但仍然只代表首页酒店展示卡片,不绑定商品本体。 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `id` | `string` | 是 | 后端生成的酒店卡片 ID | +| `title` | `string` | 是 | 酒店卡片标题 | +| `description` | `string \| null` | 否 | 酒店卡片描述 | +| `image` | `string \| null` | 否 | 兼容旧字段;后端会与 `coverImage` 保持一致 | +| `coverImage` | `string \| null` | 否 | 酒店封面图主字段;MiniAPP 优先使用该字段 | +| `priceAmount` | `number \| null` | 否 | 价格数值,前端可按页面需要展示 | +| `priceUnit` | `string \| null` | 否 | 价格单位文案,例如 `起/晚` | +| `tags` | `string[]` | 否 | 酒店标签,最多 3 个 | +| `status` | `"draft" \| "published"` | 否 | 发布状态;Public API 只返回 `published` | +| `isActive` | `boolean` | 否 | 是否启用;Public API 只返回启用项 | +| `sortOrder` | `number` | 否 | 后台展示顺序;Public API 按该字段升序输出 | + Public API 输出规则: -- `GET /api/public/site-config` 只返回启用的 `hotelGroups` 和 `vehicleOptions`。 +- `GET /api/public/site-config` 只返回 `status="published"` 且 `isActive=true` 的 `hotelGroups`。 +- `GET /api/public/site-config` 只返回启用的 `vehicleOptions`。 - 两个数组按后台 `sortOrder` 升序返回。 -- 字段缺失、数组为空或图片为空时,MiniAPP 使用 `src/content.ts` 的本地特色酒店/万趣用车内容兜底。 +- `hotelGroups` 字段缺失、数组为空或没有可用图片时,MiniAPP 使用 `src/content.ts` 的本地特色酒店内容兜底;酒店图片优先取 `coverImage`,再取 `image`。 +- `vehicleOptions` 字段缺失、数组为空或图片为空时,MiniAPP 使用 `src/content.ts` 的本地万趣用车内容兜底。 ### `PublicProduct` @@ -193,7 +213,7 @@ Public API 输出规则: | `ctaBanners` | `CtaBanner[]` | “更多服务”卡片配置 | | `campaigns` | `Campaign[]` | 活动元信息,可用于“特价优惠”入口;当前不包含活动产品结果列表 | | `routeSections` | `RouteSection[]` | “精选线路”子分组定义 | -| `hotelGroups` | `HomeCard[]` | “特色酒店”卡片配置,只返回启用项 | +| `hotelGroups` | `HotelCard[]` | “特色酒店”卡片配置,只返回 `published` 且启用项 | | `vehicleOptions` | `HomeCard[]` | “万趣用车”卡片配置,只返回启用项 | #### 首页模块数据归属 @@ -203,7 +223,7 @@ Public API 输出规则: | 特价优惠 | `site-config.campaigns` + `/api/public/products` | 当前 Public API 只返回活动元信息,不直接返回“特价优惠结果列表”。MiniAPP 若要展示活动线路,可按活动标题、标签或后续扩展的活动产品关联从 `/api/public/products` 中筛选。 | | 精选线路 | `site-config.routeSections` + `/api/public/products` | `routeSections` 返回动态分组与 `productIds`;具体产品卡片数据由 `/api/public/products.items` 提供。后台可按任务新增、删除、停用和排序分组,用户侧不假设固定三组。 | | 更多服务 | `site-config.ctaBanners` | 返回启用服务卡片,按后台排序展示;无有效配置时回退本地 `bottomCtas` 内容。 | -| 特色酒店 | `site-config.hotelGroups` | 返回启用酒店卡片,按后台排序展示;无有效配置时回退本地内容。 | +| 特色酒店 | `site-config.hotelGroups` | 返回 `published` 且启用的酒店卡片,按后台排序展示;MiniAPP 优先消费 `coverImage`,无有效配置时回退本地内容。 | | 万趣用车 | `site-config.vehicleOptions` | 返回启用用车卡片,按后台排序展示;无有效配置时回退本地内容。 | #### 响应示例 @@ -284,6 +304,11 @@ Public API 输出规则: "title": "经典酒店", "description": "城市接驳、景区度假和温泉休整,适合首游贵州的小包团动线。", "image": "/assets/guizhou/bailian-hot-spring.jpg", + "coverImage": "/assets/guizhou/bailian-hot-spring.jpg", + "priceAmount": 68000, + "priceUnit": "起/晚", + "tags": ["温泉", "亲子"], + "status": "published", "isActive": true, "sortOrder": 0 } @@ -476,7 +501,7 @@ Public API 输出规则: - “精选线路”由 `site-config.routeSections` 定义动态分组标题、副文案和商品 ID 顺序,由 `/api/public/products.items` 提供产品详情;客户端不依赖固定分组 ID 或固定三组数量。 - `routeSections` 缺失、为空或无法匹配到有效商品时,MiniAPP 使用 `src/content.ts` 的本地精选线路内容回退。 - `ctaBanners` 缺失或为空时,MiniAPP 使用 `src/content.ts` 的本地 `bottomCtas` 内容回退。 -- “特色酒店”和“万趣用车”分别由 `site-config.hotelGroups`、`site-config.vehicleOptions` 提供;字段缺失、数组为空或图片为空时使用本地内容兜底。 +- “特色酒店”和“万趣用车”分别由 `site-config.hotelGroups`、`site-config.vehicleOptions` 提供;特色酒店优先使用 `coverImage`,字段缺失、数组为空或图片为空时使用本地内容兜底。 - “特价优惠”当前没有独立 Public 结果列表字段;`site-config.campaigns` 只提供活动元信息,活动线路需通过产品标签/关键词筛选或后续扩展活动产品关联字段。 - 产品搜索当前主要在前端执行,依赖 `title`、`tags`、`destination.name`、`summary`。 - 产品详情页当前使用已加载的产品列表数据;后续可改为进入详情页时请求 `GET /api/public/products/{product_id}`。 @@ -489,7 +514,7 @@ Public API 输出规则: - 为 `GET /api/public/site-config` 验证返回 JSON 包含 `heroSlides`、`destinations`、`map`、`themes`、`ctaBanners`、`campaigns`、`routeSections`、`hotelGroups`、`vehicleOptions` 数组字段。 - 为 `GET /api/public/site-config` 验证 `routeSections` 表达“精选线路”子分组;接口返回当前已配置且启用的分组,未配置时返回空数组并由 MiniAPP 本地内容兜底。 - 为 `GET /api/public/site-config` 验证 `routeSections` 只返回启用分组,且 `productIds` 不包含未发布商品。 -- 为 `GET /api/public/site-config` 验证 `hotelGroups` 和 `vehicleOptions` 只返回启用卡片,并按 `sortOrder` 升序。 +- 为 `GET /api/public/site-config` 验证 `hotelGroups` 只返回 `published` 且启用卡片,包含 `coverImage`、价格和标签字段;验证 `vehicleOptions` 只返回启用卡片,并按 `sortOrder` 升序。 - 为 `GET /api/public/products` 验证响应结构为 `{ items: [...] }`,并覆盖 `keyword`、`destinationId`、`status`、`take` 参数。 - 为 `GET /api/public/products/{product_id}` 验证 UUID、数字 `sourceId` 和 404 场景。 - 为 `GET /api/public/destinations` 验证只返回启用目的地及别名字段。 diff --git a/tests/test_api_contracts.py b/tests/test_api_contracts.py index 71f289c..df453f4 100644 --- a/tests/test_api_contracts.py +++ b/tests/test_api_contracts.py @@ -187,6 +187,11 @@ def make_hotel_group(**overrides): title=overrides.get("title", "经典酒店"), description=overrides.get("description", "经典城市酒店与山野度假住宿组合"), image=overrides.get("image", "/assets/hotel.jpg"), + coverImage=overrides.get("coverImage", overrides.get("image", "/assets/hotel.jpg")), + priceAmount=overrides.get("priceAmount", 68000), + priceUnit=overrides.get("priceUnit", "起/晚"), + tags=overrides.get("tags", ["温泉", "亲子"]), + status=overrides.get("status", "published"), sortOrder=overrides.get("sortOrder", 1), isActive=overrides.get("isActive", True), createdAt=datetime(2026, 1, 1), @@ -651,6 +656,11 @@ def test_admin_site_config_includes_hotel_and_vehicle_modules(): "title": "经典酒店", "description": "经典城市酒店与山野度假住宿组合", "image": "/assets/hotel.jpg", + "coverImage": "/assets/hotel.jpg", + "priceAmount": 68000, + "priceUnit": "起/晚", + "tags": ["温泉", "亲子"], + "status": "published", "sortOrder": 1, "isActive": True, "createdAt": "2026-01-01T00:00:00", @@ -1081,6 +1091,11 @@ def test_public_site_config_includes_hotel_and_vehicle_modules(): assert result["hotelGroups"][0]["title"] == "经典酒店" assert result["hotelGroups"][0]["description"] == "经典城市酒店与山野度假住宿组合" + assert result["hotelGroups"][0]["coverImage"] == "/assets/hotel.jpg" + assert result["hotelGroups"][0]["priceAmount"] == 68000 + assert result["hotelGroups"][0]["priceUnit"] == "起/晚" + assert result["hotelGroups"][0]["tags"] == ["温泉", "亲子"] + assert result["hotelGroups"][0]["status"] == "published" assert result["vehicleOptions"][0]["title"] == "5座舒适用车" assert result["vehicleOptions"][0]["image"] == "/assets/vehicle.jpg"