feat(route-sections): add dynamic route sections

Replace hardcoded homepage featured route groups with a fully managed dynamic system:
- add database models RouteSection and RouteSectionProduct for storing route groups and their associated products
- create Alembic migration 0004_route_sections for the new tables
- extend SiteConfigPatchIn schema with subtitle and productIds fields
- refactor shared site_config utility to load dynamic route sections instead of fixed groups
- implement admin CRUD API with validation for duplicate/conflicting product associations
- update public and admin API documentation to reflect the new system
- add default route section seed data and comprehensive test coverage
This commit is contained in:
duanshuwen
2026-07-02 23:05:36 +08:00
parent 521e501992
commit 72d388a047
10 changed files with 610 additions and 43 deletions

View File

@@ -0,0 +1,54 @@
"""Add route section configuration.
Revision ID: 0004_route_sections
Revises: 0003_campaign_display_fields
Create Date: 2026-07-02
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
revision = "0004_route_sections"
down_revision = "0003_campaign_display_fields"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
existing_tables = set(inspect(bind).get_table_names())
if "RouteSection" not in existing_tables:
op.create_table(
"RouteSection",
sa.Column("id", sa.String(), nullable=False),
sa.Column("title", sa.String(), nullable=False),
sa.Column("subtitle", sa.Text(), 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"),
)
if "RouteSectionProduct" not in existing_tables:
op.create_table(
"RouteSectionProduct",
sa.Column("sectionId", sa.String(), nullable=False),
sa.Column("productId", sa.String(), nullable=False),
sa.Column("sortOrder", sa.Integer(), nullable=False, server_default="0"),
sa.ForeignKeyConstraint(["productId"], ["Product.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["sectionId"], ["RouteSection.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("sectionId", "productId"),
)
def downgrade() -> None:
bind = op.get_bind()
existing_tables = set(inspect(bind).get_table_names())
if "RouteSectionProduct" in existing_tables:
op.drop_table("RouteSectionProduct")
if "RouteSection" in existing_tables:
op.drop_table("RouteSection")

View File

@@ -149,6 +149,7 @@ class Product(Base):
destination: Mapped[Destination | None] = relationship(back_populates="products")
images: Mapped[list["ProductImage"]] = relationship(back_populates="product", cascade="all, delete-orphan")
campaignLinks: Mapped[list["CampaignProduct"]] = relationship(back_populates="product", cascade="all, delete-orphan")
routeSectionLinks: Mapped[list["RouteSectionProduct"]] = relationship(back_populates="product", cascade="all, delete-orphan")
leads: Mapped[list["Lead"]] = relationship(back_populates="sourceProduct")
orders: Mapped[list["Order"]] = relationship(back_populates="product")
@@ -196,6 +197,31 @@ class CampaignProduct(Base):
product: Mapped[Product] = relationship(back_populates="campaignLinks")
class RouteSection(Base):
__tablename__ = "RouteSection"
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
title: Mapped[str] = mapped_column(String, nullable=False)
subtitle: Mapped[str | None] = mapped_column(Text)
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)
products: Mapped[list["RouteSectionProduct"]] = relationship(back_populates="section", cascade="all, delete-orphan")
class RouteSectionProduct(Base):
__tablename__ = "RouteSectionProduct"
sectionId: Mapped[str] = mapped_column(String, ForeignKey("RouteSection.id", ondelete="CASCADE"), primary_key=True)
productId: Mapped[str] = mapped_column(String, ForeignKey("Product.id", ondelete="CASCADE"), primary_key=True)
sortOrder: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
section: Mapped[RouteSection] = relationship(back_populates="products")
product: Mapped[Product] = relationship(back_populates="routeSectionLinks")
class Lead(Base):
__tablename__ = "Lead"

91
app/route_sections.py Normal file
View File

@@ -0,0 +1,91 @@
from fastapi import HTTPException
from sqlalchemy import delete, select
from sqlalchemy.orm import Session, selectinload
from .models import Product, RouteSection, RouteSectionProduct
from .serializers import model_dict
ROUTE_SECTION_DEFAULTS = [
("routes", "经典人文打卡线路", "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联", 0, 8),
("routes-outdoor", "极限山野户外野咖线路", "溶洞、峡谷、漂流、峰林骑行和山野咖啡组合", 8, 16),
("routes-mix", "人文+户外综合混搭线路", "非遗村寨、古城夜游、自然轻探险和精品住宿同程安排", 16, 24),
]
def route_section_error(status_code: int, message: str, code: str, details: dict | None = None) -> None:
raise HTTPException(status_code=status_code, detail={"message": message, "code": code, "details": details or {}})
def route_section_dict(item: RouteSection, *, public: bool = False) -> dict:
links = sorted(item.products or [], key=lambda link: (link.sortOrder, link.productId))
if public:
links = [link for link in links if link.product and link.product.status == "published"]
data = {
"id": item.id,
"title": item.title,
"subtitle": item.subtitle,
"productIds": [link.productId for link in links],
"isActive": item.isActive,
}
if not public:
base = model_dict(item)
data.update(
{
"sortOrder": item.sortOrder,
"createdAt": base["createdAt"],
"updatedAt": base["updatedAt"],
}
)
return data
def route_section_query():
return (
select(RouteSection)
.options(selectinload(RouteSection.products).selectinload(RouteSectionProduct.product))
.order_by(RouteSection.sortOrder.asc())
)
def validate_route_section_product_ids(db: Session, section_id: str, product_ids: list[str]) -> None:
if len(set(product_ids)) != len(product_ids):
route_section_error(422, "线路商品不能重复", "ROUTE_SECTION_PRODUCT_DUPLICATE", {"sectionId": section_id})
if not product_ids:
return
products = db.scalars(select(Product).where(Product.id.in_(product_ids))).all()
found_ids = {product.id for product in products}
missing_ids = [product_id for product_id in product_ids if product_id not in found_ids]
if missing_ids:
route_section_error(422, "线路商品不存在", "ROUTE_SECTION_PRODUCT_NOT_FOUND", {"productIds": missing_ids})
conflicts = db.scalars(
select(RouteSectionProduct).where(
RouteSectionProduct.productId.in_(product_ids),
RouteSectionProduct.sectionId != section_id,
)
).all()
if conflicts:
conflict = conflicts[0]
route_section_error(
409,
"同一线路商品不能同时属于多个精选线路子分组",
"ROUTE_SECTION_PRODUCT_CONFLICT",
{"productId": conflict.productId, "sectionId": conflict.sectionId},
)
def replace_route_section_products(db: Session, section: RouteSection, product_ids: list[str]) -> None:
validate_route_section_product_ids(db, section.id, product_ids)
db.execute(delete(RouteSectionProduct).where(RouteSectionProduct.sectionId == section.id))
db.flush()
section.products = []
for index, product_id in enumerate(product_ids):
link = RouteSectionProduct(sectionId=section.id, productId=product_id, sortOrder=index)
section.products.append(link)
db.add(link)

View File

@@ -25,12 +25,15 @@ from ..models import (
MediaAsset,
Product,
ProductImage,
RouteSection,
RouteSectionProduct,
SiteVersion,
ThemeCard,
utc_now,
)
from ..schemas import AdminProductQuery, LeadQuery, LeadStatus, LeadStatusIn, LoginIn, ProductCreateIn, ProductStatus, ProductUpdateIn, SiteConfigPatchIn, SiteConfigReorderIn
from ..seed import create_media, reset_guizhou_content
from ..route_sections import replace_route_section_products, route_section_dict, route_section_query
from ..serializers import admin_product_dict, destination_dict, encode_value, lead_dict, model_dict
from .shared import site_config
@@ -91,6 +94,15 @@ SITE_CONFIG_MODULES = {
"create_defaults": {"description": None, "coverImage": None, "priceAmount": None, "priceUnit": "起/人", "tags": [], "status": "draft", "startsAt": None, "endsAt": None},
"ordered": False,
},
"routeSections": {
"model": RouteSection,
"entity": "route_section",
"primary": "title",
"fields": {"title", "subtitle", "isActive", "sortOrder", "productIds"},
"none_to_empty": set(),
"empty_to_none": {"subtitle"},
"create_defaults": {},
},
"ctaBanners": {
"model": CtaBanner,
"entity": "cta_banner",
@@ -360,12 +372,16 @@ def site_item_dict(module: str, item) -> dict:
return hero_slide_admin_dict(item)
if module == "map":
return map_image_admin_dict(item)
if module == "routeSections":
return route_section_dict(item)
return destination_dict(item) if module == "destinations" else model_dict(item)
def module_items(db: Session, config: dict) -> list:
model = config["model"]
stmt = select(model)
if model is RouteSection:
stmt = stmt.options(selectinload(RouteSection.products).selectinload(RouteSectionProduct.product))
if config.get("ordered", True):
stmt = stmt.order_by(model.sortOrder.asc())
else:
@@ -383,7 +399,7 @@ def site_create_payload(module: str, config: dict, body: SiteConfigPatchIn, db:
payload = {
field: site_field_value(config, field, getattr(body, field))
for field in config["fields"]
if field in fields
if field in fields and field != "productIds"
}
payload[config["primary"]] = validate_site_primary(config, body)
for field, value in config["create_defaults"].items():
@@ -411,6 +427,8 @@ def apply_site_patch(module: str, config: dict, item, body: SiteConfigPatchIn) -
for field in config["fields"]:
if field not in fields:
continue
if field == "productIds":
continue
value = site_field_value(config, field, getattr(body, field))
if field == config["primary"] and not value:
site_config_error(422, "必填字段不能为空", "MODULE_CONFIG_VALIDATION_ERROR", {"field": field})
@@ -423,6 +441,35 @@ def apply_site_patch(module: str, config: dict, item, body: SiteConfigPatchIn) -
setattr(item, field, value)
def create_route_section_config(body: SiteConfigPatchIn, request: Request, db: Session) -> dict:
config = site_module("routeSections")
items = module_items(db, config)
fields = body.model_fields_set
now = utc_now()
section = RouteSection(
title=validate_site_primary(config, body),
subtitle=site_field_value(config, "subtitle", body.subtitle) if "subtitle" in fields else None,
sortOrder=(
site_field_value(config, "sortOrder", body.sortOrder)
if "sortOrder" in fields and body.sortOrder is not None
else max([item.sortOrder for item in items], default=-1) + 1
),
isActive=site_field_value(config, "isActive", body.isActive) if "isActive" in fields else True,
createdAt=now,
updatedAt=now,
)
section.products = []
db.add(section)
db.flush()
if "productIds" in fields:
replace_route_section_products(db, section, body.productIds or [])
db.flush()
after = route_section_dict(section)
audit(db, get_actor_id(request), "create", config["entity"], section.id, after)
db.commit()
return after
def load_product(db: Session, product_id: str) -> Product:
return db.scalars(
select(Product)
@@ -581,6 +628,7 @@ def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = D
model_dict(item)
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()],
}
@@ -593,6 +641,10 @@ def create_site_config(
db: Session = Depends(get_db),
):
config = site_module(module)
if module == "routeSections":
return create_route_section_config(body, request, db)
if config.get("fixed"):
site_config_error(405, "固定模块不支持新增", "MODULE_CONFIG_CREATE_UNSUPPORTED", {"module": module})
if config.get("singleton") and module_items(db, config):
site_config_error(409, "地图图片已存在", "MAP_IMAGE_ALREADY_EXISTS", {"module": module})
item = config["model"](**site_create_payload(module, config, body, db))
@@ -650,6 +702,8 @@ def update_site_config(
site_config_error(404, "维护项不存在", "MODULE_CONFIG_NOT_FOUND", {"module": module, "id": item_id})
before = site_item_dict(module, item)
apply_site_patch(module, config, item, body)
if module == "routeSections" and "productIds" in body.model_fields_set:
replace_route_section_products(db, item, body.productIds or [])
db.flush()
after = site_item_dict(module, item)
audit(db, get_actor_id(request), "update", config["entity"], item.id, after, before)
@@ -666,6 +720,8 @@ def delete_site_config(
db: Session = Depends(get_db),
):
config = site_module(module)
if config.get("fixed"):
site_config_error(405, "固定模块不支持删除", "MODULE_CONFIG_DELETE_UNSUPPORTED", {"module": module})
item = db.get(config["model"], item_id)
if not item:
site_config_error(404, "维护项不存在", "MODULE_CONFIG_NOT_FOUND", {"module": module, "id": item_id})

View File

@@ -1,16 +1,10 @@
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from ..models import Campaign, CtaBanner, Destination, HeroSlide, MapImage, Product, ThemeCard
from ..models import Campaign, CtaBanner, Destination, HeroSlide, MapImage, ThemeCard
from ..route_sections import route_section_dict, route_section_query
from ..serializers import destination_dict, model_dict
ROUTE_SECTION_LABELS = [
("routes", "经典人文打卡线路", 0, 8),
("routes-outdoor", "极限山野户外野咖线路", 8, 16),
("routes-mix", "人文+户外综合混搭线路", 16, 24),
]
def site_config(db: Session, active_only: bool, include_public_extras: bool = True) -> dict:
hero_stmt = select(HeroSlide).order_by(HeroSlide.sortOrder.asc())
destination_stmt = select(Destination).options(selectinload(Destination.aliases)).order_by(Destination.sortOrder.asc())
@@ -38,12 +32,9 @@ def site_config(db: Session, active_only: bool, include_public_extras: bool = Tr
}
if include_public_extras:
campaigns = db.scalars(select(Campaign).where(Campaign.status == "published").order_by(Campaign.updatedAt.desc())).all()
products = db.scalars(
select(Product).where(Product.status == "published").order_by(Product.sortWeight.asc(), Product.createdAt.asc()).limit(48)
).all()
route_sections = db.scalars(route_section_query()).all()
if active_only:
route_sections = [section for section in route_sections if section.isActive]
result["campaigns"] = [model_dict(item) for item in campaigns]
result["routeSections"] = [
{"id": section_id, "title": title, "productIds": [item.id for item in products[start:end]]}
for section_id, title, start, end in ROUTE_SECTION_LABELS
]
result["routeSections"] = [route_section_dict(item, public=True) for item in route_sections]
return result

View File

@@ -121,6 +121,7 @@ class LeadQuery(BaseModel):
class SiteConfigPatchIn(BaseModel):
title: str | None = None
kicker: str | None = None
subtitle: str | None = None
name: str | None = None
slug: str | None = None
region: str | None = None
@@ -132,6 +133,7 @@ class SiteConfigPatchIn(BaseModel):
priceAmount: int | None = Field(default=None, ge=0)
priceUnit: str | None = None
tags: list[str] | None = None
productIds: list[str] | None = None
status: str | None = None
startsAt: datetime | None = None
endsAt: datetime | None = None

View File

@@ -21,10 +21,13 @@ from .models import (
MediaAsset,
Product,
ProductImage,
RouteSection,
RouteSectionProduct,
SiteVersion,
ThemeCard,
utc_now,
)
from .route_sections import ROUTE_SECTION_DEFAULTS
ROOT_DIR = Path(__file__).resolve().parent.parent
@@ -97,7 +100,7 @@ def load_products() -> list[dict]:
def reset_guizhou_content(db: Session) -> dict:
products = load_products()
for model in [SiteVersion, CampaignProduct, Campaign, ProductImage, Product, CtaBanner, ThemeCard, HeroSlide, DestinationAlias, Destination, MediaAsset]:
for model in [SiteVersion, CampaignProduct, RouteSectionProduct, Campaign, RouteSection, ProductImage, Product, CtaBanner, ThemeCard, HeroSlide, DestinationAlias, Destination, MediaAsset]:
db.execute(delete(model))
db.flush()
@@ -178,6 +181,18 @@ def reset_guizhou_content(db: Session) -> dict:
for index, product in enumerate(linked_products):
db.add(CampaignProduct(campaignId=campaign.id, productId=product.id, sortOrder=index))
for sort_order, (section_id, title, subtitle, start, end) in enumerate(ROUTE_SECTION_DEFAULTS):
section = RouteSection(id=section_id, title=title, subtitle=subtitle, sortOrder=sort_order)
db.add(section)
db.flush()
section_products = db.scalars(
select(Product)
.where(Product.sourceId >= start + 1, Product.sourceId <= end)
.order_by(Product.sourceId.asc())
).all()
for index, product in enumerate(section_products):
db.add(RouteSectionProduct(sectionId=section.id, productId=product.id, sortOrder=index))
snapshot = SiteVersion(
title="guizhou-content-reset",
status="published",
@@ -187,6 +202,7 @@ def reset_guizhou_content(db: Session) -> dict:
"destinations": len(DESTINATIONS),
"themeCards": len(THEMES),
"products": len(products),
"routeSections": len(ROUTE_SECTION_DEFAULTS),
},
)
db.add(snapshot)
@@ -197,6 +213,7 @@ def reset_guizhou_content(db: Session) -> dict:
"themes": len(THEMES),
"ctaBanners": len(CTAS),
"products": len(products),
"routeSections": len(ROUTE_SECTION_DEFAULTS),
"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` 的实际类型、请求封装和页面调用。
@@ -34,7 +34,7 @@ Content-Type: application/json
| `POST /api/admin/products` | 新建商品 | 已覆盖 | 返回完整 Product |
| `PATCH /api/admin/products/{id}` | 编辑商品 | 已覆盖 | 返回完整 Product |
| `GET /api/admin/destinations` | 商品目的地下拉、目的地页 | 已覆盖 | 需要返回别名和商品数 |
| `GET /api/admin/site-config` | 首页/目的地/活动结构维护 | 已覆盖 | 需要包含未启用内容 |
| `GET /api/admin/site-config` | 首页/目的地/活动结构维护 | 已覆盖 | 需要包含未启用内容和已保存的 `routeSections` |
| `PATCH /api/admin/site-config/{module}/{item_id}` | 模块内容编辑 | 已覆盖 | 模块名需保持一致 |
| `GET /api/admin/leads` | 需求线索页 | 已覆盖 | UI 当前不传筛选参数 |
| `PATCH /api/admin/leads/{id}/status` | 线索状态流转 | 已覆盖 | UI 更新后会重新拉列表 |
@@ -58,7 +58,7 @@ type LeadStatus = "new" | "assigned" | "contacted" | "planning" | "won" | "inval
### SiteModule
```ts
type SiteModule = "heroSlides" | "destinations" | "themes" | "ctaBanners";
type SiteModule = "heroSlides" | "destinations" | "map" | "themes" | "campaigns" | "routeSections" | "ctaBanners";
```
## 公共数据结构
@@ -188,6 +188,27 @@ type SiteConfig = {
targetValue?: string | null;
isActive: boolean;
}>;
campaigns: Array<{
id: string;
slug: string;
title: string;
description?: string | null;
coverImage?: string | null;
priceAmount?: number | null;
priceUnit?: string | null;
tags: string[];
status: "draft" | "published";
startsAt?: string | null;
endsAt?: string | null;
}>;
routeSections: Array<{
id: string;
title: string;
subtitle?: string | null;
productIds: string[];
isActive: boolean;
sortOrder: number;
}>;
ctaBanners: Array<{
id: string;
alt: string;
@@ -204,14 +225,28 @@ type SiteConfig = {
```ts
type SiteItemPatch = {
title?: string;
subtitle?: string | null;
kicker?: string;
name?: string;
slug?: string;
region?: string | null;
label?: string;
alt?: string;
image?: string | null;
description?: string | null;
coverImage?: string | null;
priceAmount?: number | null;
priceUnit?: string | null;
tags?: string[];
targetType?: string | null;
targetValue?: string | null;
isHot?: boolean;
isActive?: boolean;
sortOrder?: number;
productIds?: string[];
status?: "draft" | "published";
startsAt?: string | null;
endsAt?: string | null;
};
```
@@ -222,6 +257,8 @@ type SiteItemPatch = {
| `heroSlides` | `title``kicker``image``targetType``targetValue``isActive` |
| `destinations` | `name``image``isActive` |
| `themes` | `label``image``targetType``targetValue``isActive` |
| `campaigns` | `title``description``coverImage``priceAmount``priceUnit``tags``status` |
| `routeSections` | `title``subtitle``productIds``isActive``sortOrder` |
| `ctaBanners` | `alt``image``targetType``targetValue``isActive` |
## 接口明细
@@ -372,9 +409,10 @@ GET /api/admin/site-config
要求:
- 返回 `heroSlides``destinations``themes``ctaBanners` 个模块。
- 返回 `heroSlides``destinations``map``themes``campaigns``routeSections``ctaBanners` 个模块。
- Admin API 需要返回未启用内容Public API 才按发布/启用状态过滤。
- 各模块按 `sortOrder` 升序。
- `routeSections` 返回当前已保存的子分组,包含未启用分组和后台配置的全部 `productIds`;无数据时返回空数组。
### 更新站点配置项
@@ -386,7 +424,7 @@ PATCH /api/admin/site-config/{module}/{item_id}
| 参数 | 类型 | 说明 |
| --- | --- | --- |
| `module` | `SiteModule` | 只能为 `heroSlides``destinations``themes``ctaBanners` |
| `module` | `SiteModule` | 只能为 `heroSlides``destinations``map``themes``campaigns``routeSections``ctaBanners` |
| `item_id` | `string` | 对应模块内容项 ID |
请求体:`SiteItemPatch`
@@ -402,6 +440,26 @@ PATCH /api/admin/site-config/{module}/{item_id}
当前 UI 保存后会重新调用 `GET /api/admin/site-config` 刷新页面,响应体只需保证是合法 JSON。
#### 精选线路 `routeSections`
`routeSections` 是首页“精选线路”的动态运营分组配置,不再限制为固定三组。管理端通过新增、更新、删除和排序接口维护闭环;商品本体仍由 `/api/admin/products` 维护,这里只保存首页分组、标题、副文案、启用状态、排序和关联商品 ID 顺序。
```http
POST /api/admin/site-config/routeSections
PATCH /api/admin/site-config/routeSections/{section_id}
DELETE /api/admin/site-config/routeSections/{section_id}
PATCH /api/admin/site-config/routeSections/reorder
```
新增请求至少包含 `title`,可包含 `subtitle``isActive``sortOrder``productIds``id` 由后端生成,不再使用 `routes` / `routes-outdoor` / `routes-mix` 固定槽位。更新请求可包含 `title``subtitle``isActive``sortOrder``productIds``productIds` 表示该分组关联的线路商品及展示顺序。
后端约束:
- `GET /api/admin/site-config` 返回全部后台分组,包含停用分组和后台配置的全部 `productIds`
- `productIds` 中的商品必须存在,且同一请求内不能重复。
- 同一商品不能同时出现在多个精选线路分组;冲突时返回 `409 ROUTE_SECTION_PRODUCT_CONFLICT`
- `DELETE /api/admin/site-config/routeSections/{section_id}` 只删除分组配置并解除关联,不删除商品本体;删除后后端重新整理剩余分组 `sortOrder`
- `PATCH /api/admin/site-config/routeSections/reorder``itemIds` 必须完整覆盖当前全部分组 ID不能缺失、重复或包含未知 ID。
- `GET /api/public/site-config` 只返回启用分组,且 `productIds` 只包含已发布商品;未发布、归档或不存在的商品不进入 Public 响应。
### 线索列表
```http
@@ -485,6 +543,7 @@ POST /api/admin/reset-guizhou-content
destinations: number;
themes: number;
ctaBanners: number;
routeSections?: number;
products: number;
}
```

View File

@@ -78,14 +78,21 @@
### `RouteSection`
`RouteSection` 用于描述首页“精选线路”下的分组。`经典人文打卡线路``极限山野户外野咖线路``人文+户外综合混搭线路` 等属于“精选线路”的子集,不是独立一级模块
`RouteSection` 用于描述首页“精选线路”下的动态运营分组。后台可按任务新增、编辑、删除和排序分组MiniAPP 不应依赖固定分组 ID只按接口返回的分组顺序和 `productIds` 渲染
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `id` | `string` | 是 | 分组 ID例如 `routes``routes-outdoor``routes-mix` |
| `id` | `string` | 是 | 后端生成的分组 ID客户端只用于列表 key 和商品关联,不作为固定业务枚举 |
| `title` | `string` | 是 | 分组标题 |
| `productIds` | `string[]` | | 分组包含的产品 ID产品详情来自 `/api/public/products``items` |
| `subtitle` | `string \| null` | | 分组副文案 |
| `productIds` | `string[]` | 是 | 该分组包含的产品 ID产品详情来自 `/api/public/products.items` |
| `isActive` | `boolean` | 否 | 是否启用Public API 通常只返回启用分组 |
Public API 输出规则:
- `GET /api/public/site-config` 只返回启用的 `routeSections`
- `routeSections[].productIds` 只包含已发布商品 ID未发布、归档或不存在的商品不得出现在 Public 响应中。
- 动态分组按后台 `sortOrder` 升序返回,`productIds` 的顺序就是用户侧商品卡展示顺序。
- MiniAPP 会按 `productIds` 匹配 `/api/public/products.items[].id`;接口缺失、`routeSections` 为空或没有可匹配商品时回退 `src/content.ts` 的本地精选线路兜底内容。
### `PublicProduct`
| 字段 | 类型 | 必填 | 说明 |
@@ -168,11 +175,7 @@
| 首页模块 | 当前接口归属 | 说明 |
| --- | --- | --- |
| 特价优惠 | `site-config.campaigns` + `/api/public/products` | 当前 Public API 只返回活动元信息不直接返回“特价优惠结果列表”。MiniAPP 若要展示活动线路,可按活动标题、标签或后续扩展的活动产品关联从 `/api/public/products` 中筛选。 |
| 精选线路 | `site-config.routeSections` + `/api/public/products` | `routeSections` 返回分组与 `productIds`;具体产品卡片数据由 `/api/public/products.items` 提供。 |
| 经典人文打卡线路 | `routeSections` 子集 | 属于“精选线路”子分组,当前后端分组 ID 为 `routes`。 |
| 极限山野户外野咖线路 | `routeSections` 子集 | 属于“精选线路”子分组,当前后端分组 ID 为 `routes-outdoor`。 |
| 人文+户外综合混搭线路 | `routeSections` 子集 | 属于“精选线路”子分组,当前后端分组 ID 为 `routes-mix`。 |
| 精选线路 | `site-config.routeSections` + `/api/public/products` | `routeSections` 返回动态分组与 `productIds`;具体产品卡片数据由 `/api/public/products.items` 提供。后台可按任务新增、删除、停用和排序分组,用户侧不假设固定三组。 |
#### 响应示例
```json
@@ -224,19 +227,25 @@
],
"routeSections": [
{
"id": "routes",
"id": "route-section-001",
"title": "经典人文打卡线路",
"productIds": ["8a6e7c4f-0000-4000-9000-000000000001"]
"subtitle": "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联",
"productIds": ["8a6e7c4f-0000-4000-9000-000000000001"],
"isActive": true
},
{
"id": "routes-outdoor",
"id": "route-section-002",
"title": "极限山野户外野咖线路",
"productIds": []
"subtitle": "溶洞、峡谷、漂流、峰林骑行和山野咖啡组合",
"productIds": [],
"isActive": true
},
{
"id": "routes-mix",
"id": "route-section-003",
"title": "人文+户外综合混搭线路",
"productIds": []
"subtitle": "非遗村寨、古城夜游、自然轻探险和精品住宿同程安排",
"productIds": [],
"isActive": true
}
]
}
@@ -414,7 +423,8 @@
- `site-config``products` 会在应用启动时并行请求任一请求失败时MiniAPP 会回退到本地静态内容。
- `products.items` 为空时MiniAPP 会使用本地产品兜底数据。
- “精选线路”由 `site-config.routeSections` 定义分组,由 `/api/public/products.items` 提供产品详情;`经典人文打卡线路``极限山野户外野咖线路``人文+户外综合混搭线路` 是“精选线路”的子集
- “精选线路”由 `site-config.routeSections` 定义动态分组标题、副文案和商品 ID 顺序,由 `/api/public/products.items` 提供产品详情;客户端不依赖固定分组 ID 或固定三组数量
- `routeSections` 缺失、为空或无法匹配到有效商品时MiniAPP 使用 `src/content.ts` 的本地精选线路内容回退。
- “特价优惠”当前没有独立 Public 结果列表字段;`site-config.campaigns` 只提供活动元信息,活动线路需通过产品标签/关键词筛选或后续扩展活动产品关联字段。
- 产品搜索当前主要在前端执行,依赖 `title``tags``destination.name``summary`
- 产品详情页当前使用已加载的产品列表数据;后续可改为进入详情页时请求 `GET /api/public/products/{product_id}`
@@ -425,7 +435,8 @@
-`GET /health` 增加或保留健康检查测试。
-`GET /api/public/site-config` 验证返回 JSON 包含 `heroSlides``destinations``map``themes``ctaBanners``campaigns``routeSections` 数组字段。
-`GET /api/public/site-config` 验证 `routeSections` 表达“精选线路”子分组,并包含 `routes``routes-outdoor``routes-mix` 三个当前约定分组
-`GET /api/public/site-config` 验证 `routeSections` 表达“精选线路”子分组;接口返回当前已配置且启用的分组,未配置时返回空数组并由 MiniAPP 本地内容兜底
-`GET /api/public/site-config` 验证 `routeSections` 只返回启用分组,且 `productIds` 不包含未发布商品。
-`GET /api/public/products` 验证响应结构为 `{ items: [...] }`,并覆盖 `keyword``destinationId``status``take` 参数。
-`GET /api/public/products/{product_id}` 验证 UUID、数字 `sourceId` 和 404 场景。
-`GET /api/public/destinations` 验证只返回启用目的地及别名字段。

View File

@@ -8,7 +8,7 @@ from app import serializers
from app.auth import hash_password, require_admin
from app.database import get_db
from app.main import create_app
from app.models import AdminUser, Campaign, CtaBanner, Destination, DestinationAlias, HeroSlide, Lead, MapImage, MediaAsset, Product, ProductImage, ThemeCard
from app.models import AdminUser, Campaign, CtaBanner, Destination, DestinationAlias, HeroSlide, Lead, MapImage, MediaAsset, Product, ProductImage, RouteSection, RouteSectionProduct, ThemeCard
from app.routers import admin as admin_router
from app.routers.admin import normalize_detail_sections, normalize_images
from app.routers.shared import site_config
@@ -208,6 +208,31 @@ def make_campaign(**overrides):
)
def make_route_section(**overrides):
section = RouteSection(
id=overrides.get("id", "routes"),
title=overrides.get("title", "经典人文打卡线路"),
subtitle=overrides.get("subtitle", "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联"),
sortOrder=overrides.get("sortOrder", 0),
isActive=overrides.get("isActive", True),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
section.products = overrides.get("products", [])
return section
def make_route_section_product(**overrides):
product = overrides.get("product", make_product(id=overrides.get("productId", "product-test")))
link = RouteSectionProduct(
sectionId=overrides.get("sectionId", "routes"),
productId=overrides.get("productId", product.id),
sortOrder=overrides.get("sortOrder", 0),
)
link.product = product
return link
def make_admin_user():
return AdminUser(
id="admin-test",
@@ -471,7 +496,7 @@ def test_site_config_create_requires_module_primary_field():
def test_admin_site_config_hero_slides_use_dedicated_contract_without_targets():
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)
try:
@@ -489,7 +514,7 @@ def test_admin_site_config_hero_slides_use_dedicated_contract_without_targets():
def test_admin_site_config_includes_map_array_with_dedicated_contract():
map_image = make_map_image()
fake_db = FakeDb(scalar_results=[[], [], [map_image], [], [], []])
fake_db = FakeDb(scalar_results=[[], [], [map_image], [], [], [], [], []])
app = authenticated_app(fake_db)
try:
@@ -515,7 +540,7 @@ def test_admin_site_config_includes_map_array_with_dedicated_contract():
def test_admin_site_config_includes_campaigns_for_special_offers():
campaign = make_campaign(status="published")
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [campaign]])
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [campaign], [], []])
app = authenticated_app(fake_db)
try:
@@ -547,6 +572,90 @@ def test_admin_site_config_includes_campaigns_for_special_offers():
assert "targetType" not in body["campaigns"][0]
def test_admin_site_config_includes_route_sections_for_featured_routes():
section = make_route_section(
products=[
make_route_section_product(productId="product-a", sortOrder=1),
make_route_section_product(productId="product-b", sortOrder=0),
],
)
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [section]])
app = authenticated_app(fake_db)
try:
response = TestClient(app).get("/api/admin/site-config")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
route_sections = response.json()["routeSections"]
assert route_sections == [
{
"id": "routes",
"title": "经典人文打卡线路",
"subtitle": "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联",
"productIds": ["product-b", "product-a"],
"isActive": True,
"sortOrder": 0,
"createdAt": "2026-01-01T00:00:00",
"updatedAt": "2026-01-02T00:00:00",
}
]
def test_site_config_create_route_section_generates_dynamic_id_and_audits():
fake_db = FakeDb(scalar_results=[[]])
app = authenticated_app(fake_db)
try:
response = TestClient(app).post(
"/api/admin/site-config/routeSections",
json={"title": " 新人文线路 ", "subtitle": " 新副文案 "},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 201
body = response.json()
assert body["id"].startswith("routesection-")
assert body["title"] == "新人文线路"
assert body["subtitle"] == "新副文案"
assert body["productIds"] == []
assert body["isActive"] is True
assert body["sortOrder"] == 0
created = fake_db.added[0]
assert isinstance(created, RouteSection)
assert created.id.startswith("routesection-")
assert fake_db.added[-1].entity == "route_section"
assert fake_db.committed
def test_route_sections_module_is_not_fixed():
assert admin_router.SITE_CONFIG_MODULES["routeSections"].get("fixed") is not True
def test_site_config_create_route_section_allows_more_than_three_groups():
items = [
make_route_section(id="route-section-a", title="经典", sortOrder=0),
make_route_section(id="route-section-b", title="户外", sortOrder=1),
make_route_section(id="route-section-c", title="混搭", sortOrder=2),
]
fake_db = FakeDb(scalar_results=[items])
app = authenticated_app(fake_db)
try:
response = TestClient(app).post("/api/admin/site-config/routeSections", json={"title": "第四组"})
finally:
app.dependency_overrides.clear()
assert response.status_code == 201
body = response.json()
assert body["title"] == "第四组"
assert body["sortOrder"] == 3
assert body["productIds"] == []
assert fake_db.committed
def test_site_config_create_hero_slide_ignores_target_fields_and_returns_dedicated_contract():
fake_db = FakeDb(scalar_values=[0])
app = authenticated_app(fake_db)
@@ -788,9 +897,67 @@ def test_site_config_patch_campaign_updates_contract_fields_only():
assert fake_db.added[-1].entity == "campaign"
def test_site_config_patch_route_section_updates_copy_and_product_order():
section = make_route_section()
product_a = make_product(id="product-a")
product_b = make_product(id="product-b")
fake_db = FakeDb(
get_result=section,
scalar_results=[[product_b, product_a], []],
execute_results=[[]],
)
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch(
"/api/admin/site-config/routeSections/routes",
json={
"title": " 新经典线路 ",
"subtitle": " 新副文案 ",
"isActive": False,
"productIds": ["product-b", "product-a"],
},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert body["title"] == "新经典线路"
assert body["subtitle"] == "新副文案"
assert body["isActive"] is False
assert body["productIds"] == ["product-b", "product-a"]
links = [item for item in fake_db.added if isinstance(item, RouteSectionProduct)]
assert [(link.productId, link.sortOrder) for link in links] == [("product-b", 0), ("product-a", 1)]
assert fake_db.added[-1].entity == "route_section"
assert fake_db.committed
def test_site_config_patch_route_section_rejects_product_used_by_another_section():
section = make_route_section(id="routes")
conflict = make_route_section_product(sectionId="route-section-other", productId="product-a")
fake_db = FakeDb(
get_result=section,
scalar_results=[[make_product(id="product-a")], [conflict]],
)
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch(
"/api/admin/site-config/routeSections/routes",
json={"productIds": ["product-a"]},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 409
assert response.json()["code"] == "ROUTE_SECTION_PRODUCT_CONFLICT"
assert not fake_db.committed
def test_public_site_config_campaigns_include_price_and_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)
@@ -813,6 +980,30 @@ def test_public_site_config_campaigns_include_price_and_tags():
]
def test_public_site_config_route_sections_filter_unpublished_products():
published = make_product(id="product-published", status="published")
draft = make_product(id="product-draft", status="draft")
section = make_route_section(
products=[
make_route_section_product(product=published, productId=published.id, sortOrder=0),
make_route_section_product(product=draft, productId=draft.id, sortOrder=1),
]
)
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [section]])
result = site_config(fake_db, active_only=True)
assert result["routeSections"] == [
{
"id": "routes",
"title": "经典人文打卡线路",
"subtitle": "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联",
"productIds": ["product-published"],
"isActive": True,
}
]
def test_site_config_patch_updates_destination_contract_fields():
destination = make_destination()
fake_db = FakeDb(get_result=destination)
@@ -925,6 +1116,32 @@ def test_site_config_reorder_rejects_duplicate_missing_and_unknown_ids(item_ids)
assert not fake_db.committed
@pytest.mark.parametrize(
"item_ids",
[
["route-section-a", "route-section-a", "route-section-b"],
["route-section-a", "route-section-b"],
["route-section-a", "route-section-b", "route-section-x"],
],
)
def test_site_config_reorder_route_sections_rejects_duplicate_missing_and_unknown_ids(item_ids):
items = [
make_route_section(id="route-section-a", title="A", sortOrder=0),
make_route_section(id="route-section-b", title="B", sortOrder=1),
make_route_section(id="route-section-c", title="C", sortOrder=2),
]
fake_db = FakeDb(scalar_results=[items])
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch("/api/admin/site-config/routeSections/reorder", json={"itemIds": item_ids})
finally:
app.dependency_overrides.clear()
assert response.status_code == 400
assert response.json()["code"] == "MODULE_CONFIG_REORDER_INVALID"
assert not fake_db.committed
def test_site_config_reorder_map_is_not_supported():
fake_db = FakeDb()
app = authenticated_app(fake_db)
@@ -953,6 +1170,49 @@ def test_site_config_reorder_campaigns_is_not_supported():
assert not fake_db.committed
def test_site_config_reorder_route_sections_reassigns_sort_order():
items = [
make_route_section(id="route-section-a", title="经典", sortOrder=0),
make_route_section(id="route-section-b", title="户外", sortOrder=1),
make_route_section(id="route-section-c", title="混搭", sortOrder=2),
]
fake_db = FakeDb(scalar_results=[items])
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch(
"/api/admin/site-config/routeSections/reorder",
json={"itemIds": ["route-section-b", "route-section-a", "route-section-c"]},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert {item.id: item.sortOrder for item in items} == {"route-section-b": 0, "route-section-a": 1, "route-section-c": 2}
assert [item["id"] for item in response.json()["items"]] == ["route-section-b", "route-section-a", "route-section-c"]
assert fake_db.added[-1].entity == "route_section"
assert fake_db.committed
def test_site_config_delete_route_section_returns_json_reorders_and_does_not_delete_products():
section = make_route_section(id="route-section-delete", title="待删除", sortOrder=1)
remaining_a = make_route_section(id="route-section-a", title="保留 A", sortOrder=0)
remaining_b = make_route_section(id="route-section-b", title="保留 B", sortOrder=2)
fake_db = FakeDb(get_result=section, scalar_results=[[remaining_a, remaining_b]])
app = authenticated_app(fake_db)
try:
response = TestClient(app).delete("/api/admin/site-config/routeSections/route-section-delete")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json() == {"id": "route-section-delete"}
assert fake_db.deleted == [section]
assert {item.id: item.sortOrder for item in [remaining_a, remaining_b]} == {"route-section-a": 0, "route-section-b": 1}
assert fake_db.added[-1].action == "delete"
assert fake_db.added[-1].entity == "route_section"
assert fake_db.committed
def test_site_config_delete_map_image_returns_json_and_audits_without_reorder():
map_image = make_map_image()
fake_db = FakeDb(get_result=map_image)