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

@@ -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,
}