feat(hotelGroups): add offer display fields and related logic

- Add new database columns (coverImage, priceAmount, priceUnit, tags, status) to HotelGroup model
- Create hotel_group_dict serializer to handle image/coverImage synchronization and tag formatting
- Update site config endpoints to use the new serializer and filter published hotel groups
- Add Alembic migration for the new database schema changes
- Update test fixtures and API contract tests for the new fields
- Revise public and admin API documentation to document the new hotel group fields and usage rules
This commit is contained in:
duanshuwen
2026-07-03 22:44:57 +08:00
parent 201e835eab
commit 1aac3c64b2
9 changed files with 188 additions and 28 deletions

View File

@@ -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)

View File

@@ -133,6 +133,11 @@ class HotelGroup(Base):
title: Mapped[str] = mapped_column(String, nullable=False) title: Mapped[str] = mapped_column(String, nullable=False)
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
image: Mapped[str | None] = mapped_column(String) 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) sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
isActive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) isActive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False) createdAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, nullable=False)

View File

@@ -36,7 +36,7 @@ from ..models import (
from ..schemas import AdminProductQuery, LeadQuery, LeadStatus, LeadStatusIn, LoginIn, ProductCreateIn, ProductStatus, ProductUpdateIn, SiteConfigPatchIn, SiteConfigReorderIn from ..schemas import AdminProductQuery, LeadQuery, LeadStatus, LeadStatusIn, LoginIn, ProductCreateIn, ProductStatus, ProductUpdateIn, SiteConfigPatchIn, SiteConfigReorderIn
from ..seed import create_media, reset_guizhou_content from ..seed import create_media, reset_guizhou_content
from ..route_sections import replace_route_section_products, route_section_dict, route_section_query 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 from .shared import site_config
@@ -109,10 +109,10 @@ SITE_CONFIG_MODULES = {
"model": HotelGroup, "model": HotelGroup,
"entity": "hotel_group", "entity": "hotel_group",
"primary": "title", "primary": "title",
"fields": {"title", "description", "image", "isActive", "sortOrder"}, "fields": {"title", "description", "image", "coverImage", "priceAmount", "priceUnit", "tags", "status", "isActive", "sortOrder"},
"none_to_empty": set(), "none_to_empty": set(),
"empty_to_none": {"description", "image"}, "empty_to_none": {"description", "image", "coverImage", "priceUnit"},
"create_defaults": {"description": None, "image": None}, "create_defaults": {"description": None, "image": None, "coverImage": None, "priceAmount": None, "priceUnit": "起/晚", "tags": [], "status": "published"},
}, },
"vehicleOptions": { "vehicleOptions": {
"model": VehicleOption, "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: 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 [] return []
tags = [item.strip() for item in value if isinstance(item, str) and item.strip()] tags = [item.strip() for item in value if isinstance(item, str) and item.strip()]
if len(tags) > 3: 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 return tags
@@ -344,9 +344,9 @@ def site_field_value(config: dict, field: str, value):
return value return value
def validate_campaign_status(value): def validate_publish_status(value):
status_value = clean_site_value(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"}) site_config_error(422, "status must be draft or published", "MODULE_CONFIG_VALIDATION_ERROR", {"field": "status"})
return status_value return status_value
@@ -394,6 +394,8 @@ def site_item_dict(module: str, item) -> dict:
return map_image_admin_dict(item) return map_image_admin_dict(item)
if module == "routeSections": if module == "routeSections":
return route_section_dict(item) return route_section_dict(item)
if module == "hotelGroups":
return hotel_group_dict(item)
return destination_dict(item) if module == "destinations" else model_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 payload["slug"] = slug
if not payload.get("priceUnit"): if not payload.get("priceUnit"):
payload["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 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}) site_config_error(422, "必填字段不能为空", "MODULE_CONFIG_VALIDATION_ERROR", {"field": field})
if module == "campaigns" and field == "slug" and not value: if module == "campaigns" and field == "slug" and not value:
site_config_error(422, "required field is empty", "MODULE_CONFIG_VALIDATION_ERROR", {"field": field}) site_config_error(422, "required field is empty", "MODULE_CONFIG_VALIDATION_ERROR", {"field": field})
if module == "campaigns" and field == "status": if module in {"campaigns", "hotelGroups"} and field == "status":
value = validate_campaign_status(value) value = validate_publish_status(value)
setattr(item, field, 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: 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()], "routeSections": [route_section_dict(item) for item in db.scalars(route_section_query()).all()],
"hotelGroups": [ "hotelGroups": [
model_dict(item) hotel_group_dict(item)
for item in db.scalars(select(HotelGroup).order_by(HotelGroup.sortOrder.asc())).all() for item in db.scalars(select(HotelGroup).order_by(HotelGroup.sortOrder.asc())).all()
], ],
"vehicleOptions": [ "vehicleOptions": [

View File

@@ -2,7 +2,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
from ..models import Campaign, CtaBanner, Destination, HeroSlide, HotelGroup, MapImage, ThemeCard, VehicleOption from ..models import Campaign, CtaBanner, Destination, HeroSlide, HotelGroup, MapImage, ThemeCard, VehicleOption
from ..route_sections import route_section_dict, route_section_query 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: 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)) map_stmt = map_stmt.where(MapImage.isActive.is_(True))
theme_stmt = theme_stmt.where(ThemeCard.isActive.is_(True)) theme_stmt = theme_stmt.where(ThemeCard.isActive.is_(True))
cta_stmt = cta_stmt.where(CtaBanner.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)) vehicle_stmt = vehicle_stmt.where(VehicleOption.isActive.is_(True))
hero_slides = db.scalars(hero_stmt).all() 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] route_sections = [section for section in route_sections if section.isActive]
result["campaigns"] = [model_dict(item) for item in campaigns] result["campaigns"] = [model_dict(item) for item in campaigns]
result["routeSections"] = [route_section_dict(item, public=True) for item in route_sections] 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()] result["vehicleOptions"] = [model_dict(item) for item in db.scalars(vehicle_stmt).all()]
return result return result

View File

@@ -145,6 +145,12 @@ def reset_guizhou_content(db: Session) -> dict:
title=group["title"], title=group["title"],
description=group.get("description"), description=group.get("description"),
image=group.get("image"), 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, sortOrder=index,
) )
) )

View File

@@ -70,6 +70,16 @@ def product_dict(product) -> dict:
return admin_product_dict(product) 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: 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)]}) data = model_dict(destination, {"aliases": [model_dict(alias) for alias in _sorted_aliases(destination)]})
if include_count: if include_count:

View File

@@ -214,6 +214,11 @@ type SiteConfig = {
title: string; title: string;
description?: string | null; description?: string | null;
image?: string | null; image?: string | null;
coverImage?: string | null;
priceAmount?: number | null;
priceUnit?: string | null;
tags?: string[];
status: "draft" | "published";
isActive: boolean; isActive: boolean;
sortOrder: number; sortOrder: number;
createdAt?: string; createdAt?: string;
@@ -280,7 +285,7 @@ type SiteItemPatch = {
| `themes` | `label``image``targetType``targetValue``isActive` | | `themes` | `label``image``targetType``targetValue``isActive` |
| `campaigns` | `title``description``coverImage``priceAmount``priceUnit``tags``status` | | `campaigns` | `title``description``coverImage``priceAmount``priceUnit``tags``status` |
| `routeSections` | `title``subtitle``productIds``isActive``sortOrder` | | `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` | | `vehicleOptions` | `title``description``image``isActive``sortOrder` |
| `ctaBanners` | `alt`(服务标题)、`image``targetType``targetValue``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` - `DELETE /api/admin/site-config/routeSections/{section_id}` 只删除分组配置并解除关联,不删除商品本体;删除后后端重新整理剩余分组 `sortOrder`
- `PATCH /api/admin/site-config/routeSections/reorder``itemIds` 必须完整覆盖当前全部分组 ID不能缺失、重复或包含未知 ID。 - `PATCH /api/admin/site-config/routeSections/reorder``itemIds` 必须完整覆盖当前全部分组 ID不能缺失、重复或包含未知 ID。
- `GET /api/public/site-config` 只返回启用分组,且 `productIds` 只包含已发布商品;未发布、归档或不存在的商品不进入 Public 响应。 - `GET /api/public/site-config` 只返回启用分组,且 `productIds` 只包含已发布商品;未发布、归档或不存在的商品不进入 Public 响应。
#### 特色酒店和万趣用车 `hotelGroups` / `vehicleOptions` #### 特色酒店 `hotelGroups`
`hotelGroups` 首页“特色酒店”卡片配置,`vehicleOptions` 是首页“万趣用车”卡片配置。两者只维护首页模块卡片,不维护商品本体商品关联。 `hotelGroups` 对应首页“特色酒店”模块。该模块已按“特价优惠”的数据配置方式调整,运营可以新增、编辑、删除、排序酒店卡片,并维护标题、描述、价格、标签、封面图和前台展示状态。它只维护首页酒店卡片,不绑定商品本体,也不读取线路商品关联。
```http ```http
POST /api/admin/site-config/hotelGroups POST /api/admin/site-config/hotelGroups
PATCH /api/admin/site-config/hotelGroups/{item_id} PATCH /api/admin/site-config/hotelGroups/{item_id}
DELETE /api/admin/site-config/hotelGroups/{item_id} DELETE /api/admin/site-config/hotelGroups/{item_id}
PATCH /api/admin/site-config/hotelGroups/reorder 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 POST /api/admin/site-config/vehicleOptions
PATCH /api/admin/site-config/vehicleOptions/{item_id} PATCH /api/admin/site-config/vehicleOptions/{item_id}
DELETE /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`
- 更新请求可包含 `title``description``image``isActive``sortOrder` - 更新请求可包含 `title``description``image``isActive``sortOrder`
- 删除只删除首页卡片配置,不删除商品、目的地或素材库资源;删除后后端重新整理剩余项 `sortOrder` - 删除只删除首页用车卡片配置,不删除素材库资源;删除后后端重新整理剩余项 `sortOrder`
- `PATCH /reorder``itemIds` 必须完整覆盖当前同模块全部配置项 ID不能缺失、重复或包含未知 ID。 - `PATCH /reorder``itemIds` 必须完整覆盖当前 `vehicleOptions` 全部配置项 ID不能缺失、重复或包含未知 ID。
- `GET /api/public/site-config` 只返回启用卡片,并按 `sortOrder` 升序MiniAPP 在字段缺失或空数组时使用本地内容兜底。 - `GET /api/public/site-config` 只返回启用用车卡片,并按 `sortOrder` 升序MiniAPP 在字段缺失或空数组时使用本地内容兜底。
#### 更多服务 `ctaBanners` #### 更多服务 `ctaBanners`

View File

@@ -101,7 +101,7 @@ Public API 输出规则:
### `HomeCard` ### `HomeCard`
`HomeCard` 用于首页“特色酒店”与“万趣用车”两个普通卡片模块。MiniAPP 只消费卡片展示字段,不在这两个模块里读取商品本体或线路商品关联。 `HomeCard` 用于首页“万趣用车”等普通内容卡片模块。MiniAPP 只消费卡片展示字段,不在这模块里读取商品本体或线路商品关联。
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@@ -112,10 +112,30 @@ Public API 输出规则:
| `isActive` | `boolean` | 否 | 是否启用Public API 通常只返回启用卡片 | | `isActive` | `boolean` | 否 | 是否启用Public API 通常只返回启用卡片 |
| `sortOrder` | `number` | 否 | 后台展示顺序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 输出规则: 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` 升序返回。 - 两个数组按后台 `sortOrder` 升序返回。
- 字段缺失、数组为空或图片为空MiniAPP 使用 `src/content.ts` 的本地特色酒店/万趣用车内容兜底。 - `hotelGroups` 字段缺失、数组为空或没有可用图片时MiniAPP 使用 `src/content.ts` 的本地特色酒店内容兜底;酒店图片优先取 `coverImage`,再取 `image`
- `vehicleOptions` 字段缺失、数组为空或图片为空时MiniAPP 使用 `src/content.ts` 的本地万趣用车内容兜底。
### `PublicProduct` ### `PublicProduct`
@@ -193,7 +213,7 @@ Public API 输出规则:
| `ctaBanners` | `CtaBanner[]` | “更多服务”卡片配置 | | `ctaBanners` | `CtaBanner[]` | “更多服务”卡片配置 |
| `campaigns` | `Campaign[]` | 活动元信息,可用于“特价优惠”入口;当前不包含活动产品结果列表 | | `campaigns` | `Campaign[]` | 活动元信息,可用于“特价优惠”入口;当前不包含活动产品结果列表 |
| `routeSections` | `RouteSection[]` | “精选线路”子分组定义 | | `routeSections` | `RouteSection[]` | “精选线路”子分组定义 |
| `hotelGroups` | `HomeCard[]` | “特色酒店”卡片配置,只返回启用项 | | `hotelGroups` | `HotelCard[]` | “特色酒店”卡片配置,只返回 `published`启用项 |
| `vehicleOptions` | `HomeCard[]` | “万趣用车”卡片配置,只返回启用项 | | `vehicleOptions` | `HomeCard[]` | “万趣用车”卡片配置,只返回启用项 |
#### 首页模块数据归属 #### 首页模块数据归属
@@ -203,7 +223,7 @@ Public API 输出规则:
| 特价优惠 | `site-config.campaigns` + `/api/public/products` | 当前 Public API 只返回活动元信息不直接返回“特价优惠结果列表”。MiniAPP 若要展示活动线路,可按活动标题、标签或后续扩展的活动产品关联从 `/api/public/products` 中筛选。 | | 特价优惠 | `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.routeSections` + `/api/public/products` | `routeSections` 返回动态分组与 `productIds`;具体产品卡片数据由 `/api/public/products.items` 提供。后台可按任务新增、删除、停用和排序分组,用户侧不假设固定三组。 |
| 更多服务 | `site-config.ctaBanners` | 返回启用服务卡片,按后台排序展示;无有效配置时回退本地 `bottomCtas` 内容。 | | 更多服务 | `site-config.ctaBanners` | 返回启用服务卡片,按后台排序展示;无有效配置时回退本地 `bottomCtas` 内容。 |
| 特色酒店 | `site-config.hotelGroups` | 返回启用酒店卡片,按后台排序展示;无有效配置时回退本地内容。 | | 特色酒店 | `site-config.hotelGroups` | 返回 `published`启用酒店卡片,按后台排序展示;MiniAPP 优先消费 `coverImage`无有效配置时回退本地内容。 |
| 万趣用车 | `site-config.vehicleOptions` | 返回启用用车卡片,按后台排序展示;无有效配置时回退本地内容。 | | 万趣用车 | `site-config.vehicleOptions` | 返回启用用车卡片,按后台排序展示;无有效配置时回退本地内容。 |
#### 响应示例 #### 响应示例
@@ -284,6 +304,11 @@ Public API 输出规则:
"title": "经典酒店", "title": "经典酒店",
"description": "城市接驳、景区度假和温泉休整,适合首游贵州的小包团动线。", "description": "城市接驳、景区度假和温泉休整,适合首游贵州的小包团动线。",
"image": "/assets/guizhou/bailian-hot-spring.jpg", "image": "/assets/guizhou/bailian-hot-spring.jpg",
"coverImage": "/assets/guizhou/bailian-hot-spring.jpg",
"priceAmount": 68000,
"priceUnit": "起/晚",
"tags": ["温泉", "亲子"],
"status": "published",
"isActive": true, "isActive": true,
"sortOrder": 0 "sortOrder": 0
} }
@@ -476,7 +501,7 @@ Public API 输出规则:
- “精选线路”由 `site-config.routeSections` 定义动态分组标题、副文案和商品 ID 顺序,由 `/api/public/products.items` 提供产品详情;客户端不依赖固定分组 ID 或固定三组数量。 - “精选线路”由 `site-config.routeSections` 定义动态分组标题、副文案和商品 ID 顺序,由 `/api/public/products.items` 提供产品详情;客户端不依赖固定分组 ID 或固定三组数量。
- `routeSections` 缺失、为空或无法匹配到有效商品时MiniAPP 使用 `src/content.ts` 的本地精选线路内容回退。 - `routeSections` 缺失、为空或无法匹配到有效商品时MiniAPP 使用 `src/content.ts` 的本地精选线路内容回退。
- `ctaBanners` 缺失或为空时MiniAPP 使用 `src/content.ts` 的本地 `bottomCtas` 内容回退。 - `ctaBanners` 缺失或为空时MiniAPP 使用 `src/content.ts` 的本地 `bottomCtas` 内容回退。
- “特色酒店”和“万趣用车”分别由 `site-config.hotelGroups``site-config.vehicleOptions` 提供;字段缺失、数组为空或图片为空时使用本地内容兜底。 - “特色酒店”和“万趣用车”分别由 `site-config.hotelGroups``site-config.vehicleOptions` 提供;特色酒店优先使用 `coverImage`字段缺失、数组为空或图片为空时使用本地内容兜底。
- “特价优惠”当前没有独立 Public 结果列表字段;`site-config.campaigns` 只提供活动元信息,活动线路需通过产品标签/关键词筛选或后续扩展活动产品关联字段。 - “特价优惠”当前没有独立 Public 结果列表字段;`site-config.campaigns` 只提供活动元信息,活动线路需通过产品标签/关键词筛选或后续扩展活动产品关联字段。
- 产品搜索当前主要在前端执行,依赖 `title``tags``destination.name``summary` - 产品搜索当前主要在前端执行,依赖 `title``tags``destination.name``summary`
- 产品详情页当前使用已加载的产品列表数据;后续可改为进入详情页时请求 `GET /api/public/products/{product_id}` - 产品详情页当前使用已加载的产品列表数据;后续可改为进入详情页时请求 `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` 验证返回 JSON 包含 `heroSlides``destinations``map``themes``ctaBanners``campaigns``routeSections``hotelGroups``vehicleOptions` 数组字段。
-`GET /api/public/site-config` 验证 `routeSections` 表达“精选线路”子分组;接口返回当前已配置且启用的分组,未配置时返回空数组并由 MiniAPP 本地内容兜底。 -`GET /api/public/site-config` 验证 `routeSections` 表达“精选线路”子分组;接口返回当前已配置且启用的分组,未配置时返回空数组并由 MiniAPP 本地内容兜底。
-`GET /api/public/site-config` 验证 `routeSections` 只返回启用分组,且 `productIds` 不包含未发布商品。 -`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` 验证响应结构为 `{ items: [...] }`,并覆盖 `keyword``destinationId``status``take` 参数。
-`GET /api/public/products/{product_id}` 验证 UUID、数字 `sourceId` 和 404 场景。 -`GET /api/public/products/{product_id}` 验证 UUID、数字 `sourceId` 和 404 场景。
-`GET /api/public/destinations` 验证只返回启用目的地及别名字段。 -`GET /api/public/destinations` 验证只返回启用目的地及别名字段。

View File

@@ -187,6 +187,11 @@ def make_hotel_group(**overrides):
title=overrides.get("title", "经典酒店"), title=overrides.get("title", "经典酒店"),
description=overrides.get("description", "经典城市酒店与山野度假住宿组合"), description=overrides.get("description", "经典城市酒店与山野度假住宿组合"),
image=overrides.get("image", "/assets/hotel.jpg"), 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), sortOrder=overrides.get("sortOrder", 1),
isActive=overrides.get("isActive", True), isActive=overrides.get("isActive", True),
createdAt=datetime(2026, 1, 1), createdAt=datetime(2026, 1, 1),
@@ -651,6 +656,11 @@ def test_admin_site_config_includes_hotel_and_vehicle_modules():
"title": "经典酒店", "title": "经典酒店",
"description": "经典城市酒店与山野度假住宿组合", "description": "经典城市酒店与山野度假住宿组合",
"image": "/assets/hotel.jpg", "image": "/assets/hotel.jpg",
"coverImage": "/assets/hotel.jpg",
"priceAmount": 68000,
"priceUnit": "起/晚",
"tags": ["温泉", "亲子"],
"status": "published",
"sortOrder": 1, "sortOrder": 1,
"isActive": True, "isActive": True,
"createdAt": "2026-01-01T00:00:00", "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]["title"] == "经典酒店"
assert result["hotelGroups"][0]["description"] == "经典城市酒店与山野度假住宿组合" 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]["title"] == "5座舒适用车"
assert result["vehicleOptions"][0]["image"] == "/assets/vehicle.jpg" assert result["vehicleOptions"][0]["image"] == "/assets/vehicle.jpg"