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

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