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:
Binary file not shown.
Binary file not shown.
16
AGENTS.md
16
AGENTS.md
@@ -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
|
||||||
|
|||||||
33
README.md
33
README.md
@@ -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 兼容范围
|
||||||
|
|
||||||
|
|||||||
49
alembic/versions/0005_hotel_vehicle_modules.py
Normal file
49
alembic/versions/0005_hotel_vehicle_modules.py
Normal 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")
|
||||||
@@ -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"]},
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
31
app/seed.py
31
app/seed.py
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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` 验证只返回启用目的地及别名字段。
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user