feat: add hotel and vehicle option site modules

Add full support for hotel group and vehicle option site management features:
- Define SQLAlchemy models and alembic migration for the new database tables
- Add default sample content entries in content.py
- Extend admin and public API routes to support the new modules
- Update seed script to populate default hotel and vehicle data
- Update all relevant documentation and test cases
This commit is contained in:
duanshuwen
2026-07-03 20:26:44 +08:00
parent 72d388a047
commit 201e835eab
13 changed files with 426 additions and 34 deletions

View File

@@ -57,6 +57,22 @@ python -m app.seed
uvicorn app.main:app --host 0.0.0.0 --port 4000 --reload uvicorn app.main:app --host 0.0.0.0 --port 4000 --reload
``` ```
手动重启 4001 端口服务:先停止当前监听进程及其 worker再手动运行 API。
```powershell
$port = 4001
$listenerPids = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty OwningProcess -Unique
foreach ($listenerPid in $listenerPids) {
Get-CimInstance Win32_Process |
Where-Object { $_.ParentProcessId -eq $listenerPid } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
Stop-Process -Id $listenerPid -Force -ErrorAction SilentlyContinue
}
uvicorn app.main:app --host 0.0.0.0 --port 4001 --reload
```
Docker 全量启动: Docker 全量启动:
```bash ```bash

View File

@@ -85,6 +85,21 @@ python -m app.seed --no-reset
uvicorn app.main:app --host 0.0.0.0 --port 4000 --reload uvicorn app.main:app --host 0.0.0.0 --port 4000 --reload
``` ```
如果需要手动重启 4001 端口服务,先释放端口,再重新运行:
```powershell
$port = 4001
$listenerPids = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty OwningProcess -Unique
foreach ($listenerPid in $listenerPids) {
Get-CimInstance Win32_Process |
Where-Object { $_.ParentProcessId -eq $listenerPid } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
Stop-Process -Id $listenerPid -Force -ErrorAction SilentlyContinue
}
uvicorn app.main:app --host 0.0.0.0 --port 4001 --reload
```
启动后访问: 启动后访问:
- 健康检查http://localhost:4000/health - 健康检查http://localhost:4000/health
@@ -141,15 +156,15 @@ python -m app.seed
## 常用命令 ## 常用命令
| 命令 | 说明 | | 命令 | 说明 |
| --- | --- | | ------------------------------------------- | --------------------------------- |
| `uvicorn app.main:app --reload --port 4000` | 启动开发服务 | | `uvicorn app.main:app --reload --port 4000` | 启动开发服务 |
| `alembic upgrade head` | 创建或升级数据库结构 | | `alembic upgrade head` | 创建或升级数据库结构 |
| `alembic stamp head` | 标记已有数据库已处于当前 baseline | | `alembic stamp head` | 标记已有数据库已处于当前 baseline |
| `python -m app.seed` | 重置并导入初始化内容 | | `python -m app.seed` | 重置并导入初始化内容 |
| `python -m app.seed --no-reset` | 只确保默认后台账号存在 | | `python -m app.seed --no-reset` | 只确保默认后台账号存在 |
| `pytest` | 运行测试 | | `pytest` | 运行测试 |
| `docker compose up --build` | 构建并启动完整服务 | | `docker compose up --build` | 构建并启动完整服务 |
## API 兼容范围 ## API 兼容范围

View File

@@ -0,0 +1,49 @@
"""Add hotel and vehicle site modules.
Revision ID: 0005_hotel_vehicle_modules
Revises: 0004_route_sections
Create Date: 2026-07-03
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
revision = "0005_hotel_vehicle_modules"
down_revision = "0004_route_sections"
branch_labels = None
depends_on = None
def _create_card_table(table_name: str) -> None:
op.create_table(
table_name,
sa.Column("id", sa.String(), nullable=False),
sa.Column("title", sa.String(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("image", sa.String(), nullable=True),
sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"),
sa.Column("isActive", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("createdAt", sa.DateTime(), nullable=False),
sa.Column("updatedAt", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
def upgrade() -> None:
bind = op.get_bind()
existing_tables = set(inspect(bind).get_table_names())
if "HotelGroup" not in existing_tables:
_create_card_table("HotelGroup")
if "VehicleOption" not in existing_tables:
_create_card_table("VehicleOption")
def downgrade() -> None:
bind = op.get_bind()
existing_tables = set(inspect(bind).get_table_names())
if "VehicleOption" in existing_tables:
op.drop_table("VehicleOption")
if "HotelGroup" in existing_tables:
op.drop_table("HotelGroup")

View File

@@ -76,6 +76,32 @@ CTAS = [
("提交贵州出行需求", "/assets/guizhou/shuichunhe-rafting.jpg", "demand", None), ("提交贵州出行需求", "/assets/guizhou/shuichunhe-rafting.jpg", "demand", None),
] ]
HOTEL_GROUPS = [
{
"title": "经典酒店",
"image": "/assets/guizhou/bailian-hot-spring.jpg",
"description": "城市接驳、景区度假和温泉休整,适合首游贵州的小包团动线。",
},
{
"title": "野奢酒店",
"image": "/assets/guizhou/jianhe-hot-spring.jpg",
"description": "把山地、村寨、星空和温泉留给行程里的慢时刻。",
},
]
VEHICLE_OPTIONS = [
{
"title": "5座舒适用车",
"image": "/assets/guizhou/jiaxiu-tower.jpg",
"description": "适合2-4人家庭或好友小团城市接送、景区穿梭更灵活。",
},
{
"title": "9座精品商务车",
"image": "/assets/guizhou/wanfenglin.jpg",
"description": "适合5-8人同行、亲子或长辈出行留足行李和休息空间。",
},
]
CAMPAIGNS = [ CAMPAIGNS = [
{"slug": "classic-deal", "title": "经典打卡特惠", "start": 0, "end": 8, "coverImage": HERO_SLIDES[0]["image"]}, {"slug": "classic-deal", "title": "经典打卡特惠", "start": 0, "end": 8, "coverImage": HERO_SLIDES[0]["image"]},
{"slug": "outdoor-deal", "title": "山野野咖特惠", "start": 8, "end": 16, "coverImage": HERO_SLIDES[1]["image"]}, {"slug": "outdoor-deal", "title": "山野野咖特惠", "start": 8, "end": 16, "coverImage": HERO_SLIDES[1]["image"]},

View File

@@ -126,6 +126,32 @@ class CtaBanner(Base):
updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
class HotelGroup(Base):
__tablename__ = "HotelGroup"
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
title: Mapped[str] = mapped_column(String, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
image: Mapped[str | None] = mapped_column(String)
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)
updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
class VehicleOption(Base):
__tablename__ = "VehicleOption"
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
title: Mapped[str] = mapped_column(String, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
image: Mapped[str | None] = mapped_column(String)
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)
updatedAt: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
class Product(Base): class Product(Base):
__tablename__ = "Product" __tablename__ = "Product"

View File

@@ -20,6 +20,7 @@ from ..models import (
CtaBanner, CtaBanner,
Destination, Destination,
HeroSlide, HeroSlide,
HotelGroup,
Lead, Lead,
MapImage, MapImage,
MediaAsset, MediaAsset,
@@ -29,6 +30,7 @@ from ..models import (
RouteSectionProduct, RouteSectionProduct,
SiteVersion, SiteVersion,
ThemeCard, ThemeCard,
VehicleOption,
utc_now, utc_now,
) )
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
@@ -103,6 +105,24 @@ SITE_CONFIG_MODULES = {
"empty_to_none": {"subtitle"}, "empty_to_none": {"subtitle"},
"create_defaults": {}, "create_defaults": {},
}, },
"hotelGroups": {
"model": HotelGroup,
"entity": "hotel_group",
"primary": "title",
"fields": {"title", "description", "image", "isActive", "sortOrder"},
"none_to_empty": set(),
"empty_to_none": {"description", "image"},
"create_defaults": {"description": None, "image": None},
},
"vehicleOptions": {
"model": VehicleOption,
"entity": "vehicle_option",
"primary": "title",
"fields": {"title", "description", "image", "isActive", "sortOrder"},
"none_to_empty": set(),
"empty_to_none": {"description", "image"},
"create_defaults": {"description": None, "image": None},
},
"ctaBanners": { "ctaBanners": {
"model": CtaBanner, "model": CtaBanner,
"entity": "cta_banner", "entity": "cta_banner",
@@ -629,6 +649,14 @@ def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = D
for item in db.scalars(select(Campaign).order_by(Campaign.updatedAt.desc())).all() for item in db.scalars(select(Campaign).order_by(Campaign.updatedAt.desc())).all()
], ],
"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": [
model_dict(item)
for item in db.scalars(select(HotelGroup).order_by(HotelGroup.sortOrder.asc())).all()
],
"vehicleOptions": [
model_dict(item)
for item in db.scalars(select(VehicleOption).order_by(VehicleOption.sortOrder.asc())).all()
],
} }

View File

@@ -1,6 +1,6 @@
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
from ..models import Campaign, CtaBanner, Destination, HeroSlide, MapImage, ThemeCard 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, model_dict
@@ -11,12 +11,16 @@ def site_config(db: Session, active_only: bool, include_public_extras: bool = Tr
map_stmt = select(MapImage).order_by(MapImage.createdAt.asc()) map_stmt = select(MapImage).order_by(MapImage.createdAt.asc())
theme_stmt = select(ThemeCard).order_by(ThemeCard.sortOrder.asc()) theme_stmt = select(ThemeCard).order_by(ThemeCard.sortOrder.asc())
cta_stmt = select(CtaBanner).order_by(CtaBanner.sortOrder.asc()) cta_stmt = select(CtaBanner).order_by(CtaBanner.sortOrder.asc())
hotel_stmt = select(HotelGroup).order_by(HotelGroup.sortOrder.asc())
vehicle_stmt = select(VehicleOption).order_by(VehicleOption.sortOrder.asc())
if active_only: if active_only:
hero_stmt = hero_stmt.where(HeroSlide.isActive.is_(True)) hero_stmt = hero_stmt.where(HeroSlide.isActive.is_(True))
destination_stmt = destination_stmt.where(Destination.isActive.is_(True)) destination_stmt = destination_stmt.where(Destination.isActive.is_(True))
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))
vehicle_stmt = vehicle_stmt.where(VehicleOption.isActive.is_(True))
hero_slides = db.scalars(hero_stmt).all() hero_slides = db.scalars(hero_stmt).all()
destinations = db.scalars(destination_stmt).all() destinations = db.scalars(destination_stmt).all()
@@ -37,4 +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]
return result result["hotelGroups"] = [model_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

View File

@@ -8,7 +8,7 @@ from urllib.parse import quote
from sqlalchemy import delete, select from sqlalchemy import delete, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .auth import hash_password from .auth import hash_password
from .content import ALIASES, CAMPAIGNS, CTAS, DESTINATIONS, HERO_SLIDES, THEMES from .content import ALIASES, CAMPAIGNS, CTAS, DESTINATIONS, HERO_SLIDES, HOTEL_GROUPS, THEMES, VEHICLE_OPTIONS
from .database import Base, SessionLocal, engine from .database import Base, SessionLocal, engine
from .models import ( from .models import (
AdminUser, AdminUser,
@@ -18,6 +18,7 @@ from .models import (
Destination, Destination,
DestinationAlias, DestinationAlias,
HeroSlide, HeroSlide,
HotelGroup,
MediaAsset, MediaAsset,
Product, Product,
ProductImage, ProductImage,
@@ -25,6 +26,7 @@ from .models import (
RouteSectionProduct, RouteSectionProduct,
SiteVersion, SiteVersion,
ThemeCard, ThemeCard,
VehicleOption,
utc_now, utc_now,
) )
from .route_sections import ROUTE_SECTION_DEFAULTS from .route_sections import ROUTE_SECTION_DEFAULTS
@@ -100,7 +102,7 @@ def load_products() -> list[dict]:
def reset_guizhou_content(db: Session) -> dict: def reset_guizhou_content(db: Session) -> dict:
products = load_products() products = load_products()
for model in [SiteVersion, CampaignProduct, RouteSectionProduct, Campaign, RouteSection, ProductImage, Product, CtaBanner, ThemeCard, HeroSlide, DestinationAlias, Destination, MediaAsset]: for model in [SiteVersion, CampaignProduct, RouteSectionProduct, Campaign, RouteSection, ProductImage, Product, VehicleOption, HotelGroup, CtaBanner, ThemeCard, HeroSlide, DestinationAlias, Destination, MediaAsset]:
db.execute(delete(model)) db.execute(delete(model))
db.flush() db.flush()
@@ -136,6 +138,27 @@ def reset_guizhou_content(db: Session) -> dict:
create_media(db, image, "cta", alt) create_media(db, image, "cta", alt)
db.add(CtaBanner(alt=alt, image=image, targetType=target_type, targetValue=target_value, sortOrder=index)) db.add(CtaBanner(alt=alt, image=image, targetType=target_type, targetValue=target_value, sortOrder=index))
for index, group in enumerate(HOTEL_GROUPS):
create_media(db, group["image"], "hotel-group", group["title"])
db.add(
HotelGroup(
title=group["title"],
description=group.get("description"),
image=group.get("image"),
sortOrder=index,
)
)
for index, option in enumerate(VEHICLE_OPTIONS):
create_media(db, option["image"], "vehicle-option", option["title"])
db.add(
VehicleOption(
title=option["title"],
description=option.get("description"),
image=option.get("image"),
sortOrder=index,
)
)
for product in products: for product in products:
create_media(db, product.get("image"), "product", product["title"]) create_media(db, product.get("image"), "product", product["title"])
matched_destination = product.get("destinationName") if product.get("destinationName") in destination_map else None matched_destination = product.get("destinationName") if product.get("destinationName") in destination_map else None
@@ -203,6 +226,8 @@ def reset_guizhou_content(db: Session) -> dict:
"themeCards": len(THEMES), "themeCards": len(THEMES),
"products": len(products), "products": len(products),
"routeSections": len(ROUTE_SECTION_DEFAULTS), "routeSections": len(ROUTE_SECTION_DEFAULTS),
"hotelGroups": len(HOTEL_GROUPS),
"vehicleOptions": len(VEHICLE_OPTIONS),
}, },
) )
db.add(snapshot) db.add(snapshot)
@@ -214,6 +239,8 @@ def reset_guizhou_content(db: Session) -> dict:
"ctaBanners": len(CTAS), "ctaBanners": len(CTAS),
"products": len(products), "products": len(products),
"routeSections": len(ROUTE_SECTION_DEFAULTS), "routeSections": len(ROUTE_SECTION_DEFAULTS),
"hotelGroups": len(HOTEL_GROUPS),
"vehicleOptions": len(VEHICLE_OPTIONS),
"siteVersionId": snapshot.id, "siteVersionId": snapshot.id,
} }

View File

@@ -1,4 +1,4 @@
# WonderQ-Admin-UI Admin API 接口需求 # WonderQ-Admin-UI Admin API 接口需求
本文档用于指导 `WonderQ-Admin` 后端按当前 `WonderQ-Admin-UI` 管理端完成 Admin API 对接。接口需求来源于前端 `src/api.ts``src/App.tsx` 的实际类型、请求封装和页面调用。 本文档用于指导 `WonderQ-Admin` 后端按当前 `WonderQ-Admin-UI` 管理端完成 Admin API 对接。接口需求来源于前端 `src/api.ts``src/App.tsx` 的实际类型、请求封装和页面调用。
@@ -34,7 +34,7 @@ Content-Type: application/json
| `POST /api/admin/products` | 新建商品 | 已覆盖 | 返回完整 Product | | `POST /api/admin/products` | 新建商品 | 已覆盖 | 返回完整 Product |
| `PATCH /api/admin/products/{id}` | 编辑商品 | 已覆盖 | 返回完整 Product | | `PATCH /api/admin/products/{id}` | 编辑商品 | 已覆盖 | 返回完整 Product |
| `GET /api/admin/destinations` | 商品目的地下拉、目的地页 | 已覆盖 | 需要返回别名和商品数 | | `GET /api/admin/destinations` | 商品目的地下拉、目的地页 | 已覆盖 | 需要返回别名和商品数 |
| `GET /api/admin/site-config` | 首页/目的地/活动结构维护 | 已覆盖 | 需要包含未启用内容已保存的 `routeSections` | | `GET /api/admin/site-config` | 首页/目的地/活动结构维护 | 已覆盖 | 需要包含未启用内容已保存的 `routeSections``hotelGroups``vehicleOptions``ctaBanners` |
| `PATCH /api/admin/site-config/{module}/{item_id}` | 模块内容编辑 | 已覆盖 | 模块名需保持一致 | | `PATCH /api/admin/site-config/{module}/{item_id}` | 模块内容编辑 | 已覆盖 | 模块名需保持一致 |
| `GET /api/admin/leads` | 需求线索页 | 已覆盖 | UI 当前不传筛选参数 | | `GET /api/admin/leads` | 需求线索页 | 已覆盖 | UI 当前不传筛选参数 |
| `PATCH /api/admin/leads/{id}/status` | 线索状态流转 | 已覆盖 | UI 更新后会重新拉列表 | | `PATCH /api/admin/leads/{id}/status` | 线索状态流转 | 已覆盖 | UI 更新后会重新拉列表 |
@@ -58,7 +58,7 @@ type LeadStatus = "new" | "assigned" | "contacted" | "planning" | "won" | "inval
### SiteModule ### SiteModule
```ts ```ts
type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "routeSections" | "ctaBanners"; type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "routeSections" | "hotelGroups" | "vehicleOptions" | "ctaBanners";
``` ```
## 公共数据结构 ## 公共数据结构
@@ -209,6 +209,26 @@ type SiteConfig = {
isActive: boolean; isActive: boolean;
sortOrder: number; sortOrder: number;
}>; }>;
hotelGroups: Array<{
id: string;
title: string;
description?: string | null;
image?: string | null;
isActive: boolean;
sortOrder: number;
createdAt?: string;
updatedAt?: string;
}>;
vehicleOptions: Array<{
id: string;
title: string;
description?: string | null;
image?: string | null;
isActive: boolean;
sortOrder: number;
createdAt?: string;
updatedAt?: string;
}>;
ctaBanners: Array<{ ctaBanners: Array<{
id: string; id: string;
alt: string; alt: string;
@@ -216,6 +236,7 @@ type SiteConfig = {
targetType: string; targetType: string;
targetValue?: string | null; targetValue?: string | null;
isActive: boolean; isActive: boolean;
sortOrder: number;
}>; }>;
}; };
``` ```
@@ -259,7 +280,9 @@ 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` |
| `ctaBanners` | `alt``image``targetType``targetValue``isActive` | | `hotelGroups` | `title``description``image``isActive``sortOrder` |
| `vehicleOptions` | `title``description``image``isActive``sortOrder` |
| `ctaBanners` | `alt`(服务标题)、`image``targetType``targetValue``isActive``sortOrder` |
## 接口明细 ## 接口明细
@@ -409,10 +432,10 @@ GET /api/admin/site-config
要求: 要求:
- 返回 `heroSlides``destinations``map``themes``campaigns``routeSections``ctaBanners` 个模块。 - 返回 `heroSlides``destinations``map``themes``campaigns``routeSections``hotelGroups``vehicleOptions``ctaBanners` 个模块。
- Admin API 需要返回未启用内容Public API 才按发布/启用状态过滤。 - Admin API 需要返回未启用内容Public API 才按发布/启用状态过滤。
- 各模块按 `sortOrder` 升序。 - 各模块按 `sortOrder` 升序。
- `routeSections` 返回当前已保存的子分组,包含未启用分组和后台配置的全部 `productIds`;无数据时返回空数组。 - `routeSections` 返回当前已保存的子分组,包含未启用分组和后台配置的全部 `productIds`;无数据时返回空数组。`hotelGroups``vehicleOptions` 返回全部后台卡片,包含停用项;无数据时返回空数组。
### 更新站点配置项 ### 更新站点配置项
@@ -424,7 +447,7 @@ PATCH /api/admin/site-config/{module}/{item_id}
| 参数 | 类型 | 说明 | | 参数 | 类型 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `module` | `SiteModule` | 只能为 `heroSlides``destinations``map``themes``campaigns``routeSections``ctaBanners` | | `module` | `SiteModule` | 只能为 `heroSlides``destinations``map``themes``campaigns``routeSections``hotelGroups``vehicleOptions``ctaBanners` |
| `item_id` | `string` | 对应模块内容项 ID | | `item_id` | `string` | 对应模块内容项 ID |
请求体:`SiteItemPatch` 请求体:`SiteItemPatch`
@@ -460,6 +483,46 @@ 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` 是首页“特色酒店”卡片配置,`vehicleOptions` 是首页“万趣用车”卡片配置。两者只维护首页模块卡片,不维护商品本体或商品关联。
```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
POST /api/admin/site-config/vehicleOptions
PATCH /api/admin/site-config/vehicleOptions/{item_id}
DELETE /api/admin/site-config/vehicleOptions/{item_id}
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 在字段缺失或空数组时使用本地内容兜底。
#### 更多服务 `ctaBanners`
`ctaBanners` 对应首页“更多服务”模块,维护权益、服务管家、目的地和需求入口等服务卡片。管理端按顶部轮播相同的配置方式提供新增、编辑、删除和排序;`alt` 是前台卡片标题,`image` 是卡片背景图。
```http
POST /api/admin/site-config/ctaBanners
PATCH /api/admin/site-config/ctaBanners/{item_id}
DELETE /api/admin/site-config/ctaBanners/{item_id}
PATCH /api/admin/site-config/ctaBanners/reorder
```
字段规则:
- 新增请求至少包含 `alt`,可包含 `image``targetType``targetValue``isActive``sortOrder`
- 更新请求可包含 `alt``image``targetType``targetValue``isActive``sortOrder`
- 删除只删除首页更多服务卡片配置,不删除素材库资源;删除后后端重新整理剩余项 `sortOrder`
- `GET /api/public/site-config` 只返回启用卡片,并按 `sortOrder` 升序。
### 线索列表 ### 线索列表
```http ```http
@@ -544,6 +607,8 @@ POST /api/admin/reset-guizhou-content
themes: number; themes: number;
ctaBanners: number; ctaBanners: number;
routeSections?: number; routeSections?: number;
hotelGroups?: number;
vehicleOptions?: number;
products: number; products: number;
} }
``` ```

View File

@@ -26,6 +26,7 @@
| `targetType` | `string \| null` | 否 | 点击目标类型 | | `targetType` | `string \| null` | 否 | 点击目标类型 |
| `targetValue` | `string \| null` | 否 | 点击目标值 | | `targetValue` | `string \| null` | 否 | 点击目标值 |
| `isActive` | `boolean` | 否 | 是否启用 | | `isActive` | `boolean` | 否 | 是否启用 |
| `sortOrder` | `number` | 否 | 后台排序值Public API 按该字段升序输出 |
### `Destination` ### `Destination`
@@ -36,6 +37,7 @@
| `image` | `string \| null` | 否 | 图片 URL | | `image` | `string \| null` | 否 | 图片 URL |
| `isHot` | `boolean` | 否 | 是否热门 | | `isHot` | `boolean` | 否 | 是否热门 |
| `isActive` | `boolean` | 否 | 是否启用 | | `isActive` | `boolean` | 否 | 是否启用 |
| `sortOrder` | `number` | 否 | 后台排序值Public API 按该字段升序输出 |
| `aliases` | `Array<{ id: string; alias: string }>` | 否 | 搜索别名 | | `aliases` | `Array<{ id: string; alias: string }>` | 否 | 搜索别名 |
### `Theme` ### `Theme`
@@ -44,21 +46,23 @@
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `id` | `string` | 是 | 主题 ID | | `id` | `string` | 是 | 主题 ID |
| `label` | `string` | 是 | 主题名称 | | `label` | `string` | 是 | 主题名称 |
| `image` | `string` | 是 | 图片 URL | | `image` | `string` | 是 | 主题图片 URL |
| `targetType` | `string \| null` | 否 | 点击目标类型 | | `targetType` | `string \| null` | 否 | 点击目标类型 |
| `targetValue` | `string \| null` | 否 | 点击目标值 | | `targetValue` | `string \| null` | 否 | 点击目标值 |
| `isActive` | `boolean` | 否 | 是否启用 | | `isActive` | `boolean` | 否 | 是否启用 |
| `sortOrder` | `number` | 否 | 后台排序值Public API 按该字段升序输出 |
### `CtaBanner` ### `CtaBanner`
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `id` | `string` | 是 | Banner ID | | `id` | `string` | 是 | 服务卡片 ID |
| `alt` | `string` | 是 | 图片替代文案 | | `alt` | `string` | 是 | 服务标题,展示在“更多服务”卡片上 |
| `image` | `string` | 是 | 图 URL | | `image` | `string` | 是 | 服务卡片背景图 URL |
| `targetType` | `string \| null` | 否 | 点击目标类型 | | `targetType` | `string \| null` | 否 | 点击目标类型 |
| `targetValue` | `string \| null` | 否 | 点击目标值 | | `targetValue` | `string \| null` | 否 | 点击目标值 |
| `isActive` | `boolean` | 否 | 是否启用 | | `isActive` | `boolean` | 否 | 是否启用 |
| `sortOrder` | `number` | 否 | 后台排序值Public API 按该字段升序输出 |
### `Campaign` ### `Campaign`
@@ -87,12 +91,32 @@
| `subtitle` | `string \| null` | 否 | 分组副文案 | | `subtitle` | `string \| null` | 否 | 分组副文案 |
| `productIds` | `string[]` | 是 | 该分组包含的产品 ID产品详情来自 `/api/public/products.items` | | `productIds` | `string[]` | 是 | 该分组包含的产品 ID产品详情来自 `/api/public/products.items` |
| `isActive` | `boolean` | 否 | 是否启用Public API 通常只返回启用分组 | | `isActive` | `boolean` | 否 | 是否启用Public API 通常只返回启用分组 |
| `sortOrder` | `number` | 否 | 后台展示顺序Public API 按该字段升序输出 |
Public API 输出规则: Public API 输出规则:
- `GET /api/public/site-config` 只返回启用的 `routeSections` - `GET /api/public/site-config` 只返回启用的 `routeSections`
- `routeSections[].productIds` 只包含已发布商品 ID未发布、归档或不存在的商品不得出现在 Public 响应中。 - `routeSections[].productIds` 只包含已发布商品 ID未发布、归档或不存在的商品不得出现在 Public 响应中。
- 动态分组按后台 `sortOrder` 升序返回,`productIds` 的顺序就是用户侧商品卡展示顺序。 - 动态分组按后台 `sortOrder` 升序返回,`productIds` 的顺序就是用户侧商品卡展示顺序。
- MiniAPP 会按 `productIds` 匹配 `/api/public/products.items[].id`;接口缺失、`routeSections` 为空或没有可匹配商品时回退 `src/content.ts` 的本地精选线路兜底内容。 - MiniAPP 会按 `productIds` 匹配 `/api/public/products.items[].id`;接口缺失、`routeSections` 为空或没有可匹配商品时回退 `src/content.ts` 的本地精选线路兜底内容。
### `HomeCard`
`HomeCard` 用于首页“特色酒店”与“万趣用车”两个普通卡片模块。MiniAPP 只消费卡片展示字段,不在这两个模块里读取商品本体或线路商品关联。
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `id` | `string` | 是 | 后端生成的卡片 ID |
| `title` | `string` | 是 | 卡片标题 |
| `description` | `string \| null` | 否 | 卡片描述 |
| `image` | `string \| null` | 否 | 卡片封面图 URL为空时客户端可使用本地兜底图 |
| `isActive` | `boolean` | 否 | 是否启用Public API 通常只返回启用卡片 |
| `sortOrder` | `number` | 否 | 后台展示顺序Public API 按该字段升序输出 |
Public API 输出规则:
- `GET /api/public/site-config` 只返回启用的 `hotelGroups``vehicleOptions`
- 两个数组按后台 `sortOrder` 升序返回。
- 字段缺失、数组为空或图片为空时MiniAPP 使用 `src/content.ts` 的本地特色酒店/万趣用车内容兜底。
### `PublicProduct` ### `PublicProduct`
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
@@ -156,7 +180,7 @@ Public API 输出规则:
### `GET /api/public/site-config` ### `GET /api/public/site-config`
用于首页轮播、目的地、主题入口和底部 CTA 配置。MiniAPP 启动时会和产品列表并行请求该接口;接口不可用或关键数组为空时,前台会回退本地静态内容。 用于首页轮播、目的地、主题入口和更多服务配置。MiniAPP 启动时会和产品列表并行请求该接口;接口不可用或关键数组为空时,前台会回退本地静态内容。
#### 响应字段 #### 响应字段
@@ -166,9 +190,11 @@ Public API 输出规则:
| `destinations` | `Destination[]` | 首页目的地入口 | | `destinations` | `Destination[]` | 首页目的地入口 |
| `map` | `Array<{ id: string; image: string; isActive?: boolean }>` | 贵州地图图片MiniAPP 当前消费 `map[0].image` | | `map` | `Array<{ id: string; image: string; isActive?: boolean }>` | 贵州地图图片MiniAPP 当前消费 `map[0].image` |
| `themes` | `Theme[]` | 主题甄选入口 | | `themes` | `Theme[]` | 主题甄选入口 |
| `ctaBanners` | `CtaBanner[]` | 底部 CTA Banner | | `ctaBanners` | `CtaBanner[]` | “更多服务”卡片配置 |
| `campaigns` | `Campaign[]` | 活动元信息,可用于“特价优惠”入口;当前不包含活动产品结果列表 | | `campaigns` | `Campaign[]` | 活动元信息,可用于“特价优惠”入口;当前不包含活动产品结果列表 |
| `routeSections` | `RouteSection[]` | “精选线路”子分组定义 | | `routeSections` | `RouteSection[]` | “精选线路”子分组定义 |
| `hotelGroups` | `HomeCard[]` | “特色酒店”卡片配置,只返回启用项 |
| `vehicleOptions` | `HomeCard[]` | “万趣用车”卡片配置,只返回启用项 |
#### 首页模块数据归属 #### 首页模块数据归属
@@ -176,6 +202,10 @@ 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.hotelGroups` | 返回启用酒店卡片,按后台排序展示;无有效配置时回退本地内容。 |
| 万趣用车 | `site-config.vehicleOptions` | 返回启用用车卡片,按后台排序展示;无有效配置时回退本地内容。 |
#### 响应示例 #### 响应示例
```json ```json
@@ -247,6 +277,26 @@ Public API 输出规则:
"productIds": [], "productIds": [],
"isActive": true "isActive": true
} }
],
"hotelGroups": [
{
"id": "hotel-group-001",
"title": "经典酒店",
"description": "城市接驳、景区度假和温泉休整,适合首游贵州的小包团动线。",
"image": "/assets/guizhou/bailian-hot-spring.jpg",
"isActive": true,
"sortOrder": 0
}
],
"vehicleOptions": [
{
"id": "vehicle-option-001",
"title": "5座舒适用车",
"description": "适合2-4人家庭或好友小团城市接送、景区穿梭更灵活。",
"image": "/assets/guizhou/jiaxiu-tower.jpg",
"isActive": true,
"sortOrder": 0
}
] ]
} }
``` ```
@@ -425,6 +475,8 @@ Public API 输出规则:
- `products.items` 为空时MiniAPP 会使用本地产品兜底数据。 - `products.items` 为空时MiniAPP 会使用本地产品兜底数据。
- “精选线路”由 `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` 内容回退。
- “特色酒店”和“万趣用车”分别由 `site-config.hotelGroups``site-config.vehicleOptions` 提供;字段缺失、数组为空或图片为空时使用本地内容兜底。
- “特价优惠”当前没有独立 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}`
@@ -434,9 +486,10 @@ Public API 输出规则:
## 后端验证建议 ## 后端验证建议
-`GET /health` 增加或保留健康检查测试。 -`GET /health` 增加或保留健康检查测试。
-`GET /api/public/site-config` 验证返回 JSON 包含 `heroSlides``destinations``map``themes``ctaBanners``campaigns``routeSections` 数组字段。 -`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/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

@@ -8,7 +8,7 @@ from app import serializers
from app.auth import hash_password, require_admin from app.auth import hash_password, require_admin
from app.database import get_db from app.database import get_db
from app.main import create_app from app.main import create_app
from app.models import AdminUser, Campaign, CtaBanner, Destination, DestinationAlias, HeroSlide, Lead, MapImage, MediaAsset, Product, ProductImage, RouteSection, RouteSectionProduct, ThemeCard from app.models import AdminUser, Campaign, CtaBanner, Destination, DestinationAlias, HeroSlide, HotelGroup, Lead, MapImage, MediaAsset, Product, ProductImage, RouteSection, RouteSectionProduct, ThemeCard, VehicleOption
from app.routers import admin as admin_router from app.routers import admin as admin_router
from app.routers.admin import normalize_detail_sections, normalize_images from app.routers.admin import normalize_detail_sections, normalize_images
from app.routers.shared import site_config from app.routers.shared import site_config
@@ -180,6 +180,33 @@ def make_cta_banner(**overrides):
) )
def make_hotel_group(**overrides):
return HotelGroup(
id=overrides.get("id", "hotel-group-test"),
title=overrides.get("title", "经典酒店"),
description=overrides.get("description", "经典城市酒店与山野度假住宿组合"),
image=overrides.get("image", "/assets/hotel.jpg"),
sortOrder=overrides.get("sortOrder", 1),
isActive=overrides.get("isActive", True),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
def make_vehicle_option(**overrides):
return VehicleOption(
id=overrides.get("id", "vehicle-option-test"),
title=overrides.get("title", "5座舒适用车"),
description=overrides.get("description", "2-8人小团按人数和行李匹配车型"),
image=overrides.get("image", "/assets/vehicle.jpg"),
sortOrder=overrides.get("sortOrder", 2),
isActive=overrides.get("isActive", True),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
def make_map_image(**overrides): def make_map_image(**overrides):
return MapImage( return MapImage(
id=overrides.get("id", "map-test"), id=overrides.get("id", "map-test"),
@@ -442,6 +469,8 @@ def test_site_config_patch_ignores_fields_not_allowed_for_module_and_audits():
("destinations", {"name": "新目的地", "slug": "", "region": None, "isHot": True}, {"name": "新目的地", "slug": "e696b0e79baee79a84e59cb0", "isHot": True}), ("destinations", {"name": "新目的地", "slug": "", "region": None, "isHot": True}, {"name": "新目的地", "slug": "e696b0e79baee79a84e59cb0", "isHot": True}),
("themes", {"label": "新主题", "image": None}, {"label": "新主题", "image": ""}), ("themes", {"label": "新主题", "image": None}, {"label": "新主题", "image": ""}),
("ctaBanners", {"alt": "新运营入口", "image": None, "targetType": None}, {"alt": "新运营入口", "image": "", "targetType": ""}), ("ctaBanners", {"alt": "新运营入口", "image": None, "targetType": None}, {"alt": "新运营入口", "image": "", "targetType": ""}),
("hotelGroups", {"title": "经典酒店", "description": "酒店文案", "image": None}, {"title": "经典酒店", "description": "酒店文案", "image": None}),
("vehicleOptions", {"title": "5座舒适用车", "description": "用车文案", "image": None}, {"title": "5座舒适用车", "description": "用车文案", "image": None}),
], ],
) )
def test_site_config_create_modules_defaults_fields_and_audits(module, payload, expected): def test_site_config_create_modules_defaults_fields_and_audits(module, payload, expected):
@@ -496,7 +525,7 @@ def test_site_config_create_requires_module_primary_field():
def test_admin_site_config_hero_slides_use_dedicated_contract_without_targets(): def test_admin_site_config_hero_slides_use_dedicated_contract_without_targets():
hero_slide = make_hero_slide(targetType="campaign", targetValue="campaign-test") hero_slide = make_hero_slide(targetType="campaign", targetValue="campaign-test")
fake_db = FakeDb(scalar_results=[[], [hero_slide], [], [], [], [], [], []]) fake_db = FakeDb(scalar_results=[[], [hero_slide], [], [], [], [], [], [], []])
app = authenticated_app(fake_db) app = authenticated_app(fake_db)
try: try:
@@ -514,7 +543,7 @@ def test_admin_site_config_hero_slides_use_dedicated_contract_without_targets():
def test_admin_site_config_includes_map_array_with_dedicated_contract(): def test_admin_site_config_includes_map_array_with_dedicated_contract():
map_image = make_map_image() map_image = make_map_image()
fake_db = FakeDb(scalar_results=[[], [], [map_image], [], [], [], [], []]) fake_db = FakeDb(scalar_results=[[], [], [map_image], [], [], [], [], [], []])
app = authenticated_app(fake_db) app = authenticated_app(fake_db)
try: try:
@@ -540,7 +569,7 @@ def test_admin_site_config_includes_map_array_with_dedicated_contract():
def test_admin_site_config_includes_campaigns_for_special_offers(): def test_admin_site_config_includes_campaigns_for_special_offers():
campaign = make_campaign(status="published") campaign = make_campaign(status="published")
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [campaign], [], []]) fake_db = FakeDb(scalar_results=[[], [], [], [], [], [campaign], [], [], []])
app = authenticated_app(fake_db) app = authenticated_app(fake_db)
try: try:
@@ -579,7 +608,7 @@ def test_admin_site_config_includes_route_sections_for_featured_routes():
make_route_section_product(productId="product-b", sortOrder=0), make_route_section_product(productId="product-b", sortOrder=0),
], ],
) )
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [section]]) fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [section], [], []])
app = authenticated_app(fake_db) app = authenticated_app(fake_db)
try: try:
@@ -603,6 +632,45 @@ def test_admin_site_config_includes_route_sections_for_featured_routes():
] ]
def test_admin_site_config_includes_hotel_and_vehicle_modules():
hotel_group = make_hotel_group()
vehicle_option = make_vehicle_option()
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [], [hotel_group], [vehicle_option]])
app = authenticated_app(fake_db)
try:
response = TestClient(app).get("/api/admin/site-config")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert body["hotelGroups"] == [
{
"id": "hotel-group-test",
"title": "经典酒店",
"description": "经典城市酒店与山野度假住宿组合",
"image": "/assets/hotel.jpg",
"sortOrder": 1,
"isActive": True,
"createdAt": "2026-01-01T00:00:00",
"updatedAt": "2026-01-02T00:00:00",
}
]
assert body["vehicleOptions"] == [
{
"id": "vehicle-option-test",
"title": "5座舒适用车",
"description": "2-8人小团按人数和行李匹配车型",
"image": "/assets/vehicle.jpg",
"sortOrder": 2,
"isActive": True,
"createdAt": "2026-01-01T00:00:00",
"updatedAt": "2026-01-02T00:00:00",
}
]
def test_site_config_create_route_section_generates_dynamic_id_and_audits(): def test_site_config_create_route_section_generates_dynamic_id_and_audits():
fake_db = FakeDb(scalar_results=[[]]) fake_db = FakeDb(scalar_results=[[]])
app = authenticated_app(fake_db) app = authenticated_app(fake_db)
@@ -957,7 +1025,7 @@ def test_site_config_patch_route_section_rejects_product_used_by_another_section
def test_public_site_config_campaigns_include_price_and_tags(): def test_public_site_config_campaigns_include_price_and_tags():
campaign = make_campaign(status="published", tags=["自然奇景", "小众秘境"]) campaign = make_campaign(status="published", tags=["自然奇景", "小众秘境"])
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [campaign], [], []]) fake_db = FakeDb(scalar_results=[[], [], [], [], [], [campaign], [], [], []])
result = site_config(fake_db, active_only=True) result = site_config(fake_db, active_only=True)
@@ -989,7 +1057,7 @@ def test_public_site_config_route_sections_filter_unpublished_products():
make_route_section_product(product=draft, productId=draft.id, sortOrder=1), make_route_section_product(product=draft, productId=draft.id, sortOrder=1),
] ]
) )
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [section]]) fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [section], [], []])
result = site_config(fake_db, active_only=True) result = site_config(fake_db, active_only=True)
@@ -1004,6 +1072,19 @@ def test_public_site_config_route_sections_filter_unpublished_products():
] ]
def test_public_site_config_includes_hotel_and_vehicle_modules():
hotel_group = make_hotel_group()
vehicle_option = make_vehicle_option()
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [], [hotel_group], [vehicle_option]])
result = site_config(fake_db, active_only=True)
assert result["hotelGroups"][0]["title"] == "经典酒店"
assert result["hotelGroups"][0]["description"] == "经典城市酒店与山野度假住宿组合"
assert result["vehicleOptions"][0]["title"] == "5座舒适用车"
assert result["vehicleOptions"][0]["image"] == "/assets/vehicle.jpg"
def test_site_config_patch_updates_destination_contract_fields(): def test_site_config_patch_updates_destination_contract_fields():
destination = make_destination() destination = make_destination()
fake_db = FakeDb(get_result=destination) fake_db = FakeDb(get_result=destination)